diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..1ff0c423 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain diff --git a/.gitignore b/.gitignore index 3fff05a0..40364a4c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,111 +1,264 @@ -# Created by .ignore support plugin (hsz.mobi) -### Python template -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. -# C extensions -*.so +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ -# Installer logs -pip-log.txt -pip-delete-this-directory.txt +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* -# Translations -*.mo -*.pot +# NUNIT +*.VisualState.xml +TestResult.xml -# Django stuff: +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +project.fragment.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj *.log -.static_storage/ -.media/ -local_settings.py +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc -# Flask stuff: -instance/ -.webassets-cache +# Chutzpah Test files +_Chutzpah* -# Scrapy stuff: -.scrapy +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb -# Sphinx documentation -docs/_build/ +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap -# PyBuilder -target/ +# TFS 2012 Local Workspace +$tf/ -# Jupyter Notebook -.ipynb_checkpoints +# Guidance Automation Toolkit +*.gpState -# pyenv -.python-version +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user -# celery beat schedule file -celerybeat-schedule +# JustCode is a .NET coding add-in +.JustCode -# SageMath parsed files -*.sage.py +# TeamCity is a build add-in +_TeamCity* -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ +# DotCover is a Code Coverage Tool +*.dotCover -# Spyder project settings -.spyderproject -.spyproject +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* -# Rope project settings -.ropeproject +# MightyMoose +*.mm.* +AutoTest.Net/ -# mkdocs documentation -/site +# Web workbench (sass) +.sass-cache/ -# mypy -.mypy_cache/ +# Installshield output folder +[Ee]xpress/ -.idea -discord.log -/.vscode/settings.json -bot.db \ No newline at end of file +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +#*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc +launchSettings.json +bot.db +.vscode/ diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 86bf874c..00000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "name": "Python", - "type": "python", - "request": "launch", - "stopOnEntry": false, - "pythonPath": "${config:python.pythonPath}", - "program": "${workspaceFolder}/bot.py", - "args": [ - "put_bot_user_token_here" - ], - "cwd": "${workspaceFolder}", - "env": {}, - "envFile": "${workspaceFolder}/.env", - "debugOptions": [ - "RedirectOutput" - ] - } - ] -} \ No newline at end of file diff --git a/CompatApiClient/ApiConfig.cs b/CompatApiClient/ApiConfig.cs new file mode 100644 index 00000000..04052c0e --- /dev/null +++ b/CompatApiClient/ApiConfig.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace CompatApiClient +{ + using ReturnCodeType = Dictionary; + + public static class ApiConfig + { + public static int Version { get; } = 1; + public static Uri BaseUrl { get; } = new Uri("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 readonly ReturnCodeType ReturnCodes = new ReturnCodeType + { + {0, (true, false, true, "Results successfully retrieved.")}, + {1, (false, false, true, "No results.") }, + {2, (true, false, true, "No match was found, displaying results for: ***{0}***.") }, + {-1, (false, true, false, "{0}: Internal error occurred, please contact Ani and Nicba1010") }, + {-2, (false, true, false, "{0}: API is undergoing maintenance, please try again later.") }, + {-3, (false, false, false, "Illegal characters found, please try again with a different search term.") }, + }; + + public static readonly List ResultAmount = new List {25, 50, 100, 200}; + + public static readonly Dictionary Directions = new Dictionary + { + {'a', new []{"a", "asc", "ascending"}}, + {'d', new []{"d", "desc", "descending"} }, + }; + + public static readonly Dictionary Regions = new Dictionary + { + {'j', new[] {"j", "jp", "ja", "japan", "japanese", "JPN"}}, + {'u', new[] {"u", "us", "america", "american", "USA"}}, + {'e', new[] {"e", "eu", "europe", "european", "EUR"}}, + {'a', new[] {"a", "asia", "asian", "ch", "china", "chinese", "CHN"}}, + {'k', new[] {"k", "kr", "korea", "korean", "KOR"}}, + {'h', new[] {"h", "hong", "kong", "hongkong", "hong-kong", "HK"}}, + }; + + public static readonly Dictionary Statuses = new Dictionary + { + {"all", 0 }, + {"playable", 1 }, + {"ingame", 2 }, + {"intro", 3 }, + {"loadable", 4 }, + {"nothing", 5 }, + }; + + public static readonly Dictionary SortTypes = new Dictionary + { + {"id", 1 }, + {"title", 2 }, + {"status", 3 }, + {"date", 4 }, + }; + + public static readonly Dictionary ReleaseTypes = new Dictionary + { + {'b', new[] {"b", "d", "disc", "disk", "bluray", "blu-ray"}}, + {'n', new[] {"n", "p", "PSN"}}, + }; + + public static readonly Dictionary ReverseDirections; + public static readonly Dictionary ReverseRegions; + public static readonly Dictionary ReverseReleaseTypes; + public static readonly Dictionary ReverseStatuses; + public static readonly Dictionary ReverseSortTypes; + + private static Dictionary Reverse(this Dictionary dic, IEqualityComparer comparer) + { + return ( + from kvp in dic + from val in kvp.Value + select (val, kvp.Key) + ).ToDictionary(rkvp => rkvp.val, rkvp => rkvp.Key, comparer); + } + + static ApiConfig() + { + try + { + ReverseDirections = Directions.Reverse(StringComparer.InvariantCultureIgnoreCase); + ReverseRegions = Regions.Reverse(StringComparer.InvariantCultureIgnoreCase); + ReverseReleaseTypes = ReleaseTypes.Reverse(StringComparer.InvariantCultureIgnoreCase); + ReverseStatuses = Statuses.ToDictionary(kvp => kvp.Value, kvp => kvp.Key); + ReverseSortTypes = SortTypes.ToDictionary(kvp => kvp.Value, kvp => kvp.Key); + } + catch (Exception e) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine(e); + Console.ResetColor(); + } + } + } +} \ No newline at end of file diff --git a/CompatApiClient/Client.cs b/CompatApiClient/Client.cs new file mode 100644 index 00000000..df6359bc --- /dev/null +++ b/CompatApiClient/Client.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Formatting; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using CompatApiClient.Compression; +using CompatApiClient.POCOs; +using Newtonsoft.Json; + +namespace CompatApiClient +{ + public class Client + { + private readonly HttpClient client; + private readonly MediaTypeFormatterCollection formatters; + + private static readonly Dictionary prInfoCache = new Dictionary(); + + public Client() + { + client = HttpClientFactory.Create(new CompressionMessageHandler()); + var settings = new JsonSerializerSettings + { + ContractResolver = new JsonContractResolver(NamingStyles.Underscore), + NullValueHandling = NullValueHandling.Ignore + }; + formatters = new MediaTypeFormatterCollection(new[] {new JsonMediaTypeFormatter {SerializerSettings = settings}}); + } + + //todo: cache results + public async Task GetCompatResultAsync(RequestBuilder requestBuilder, CancellationToken cancellationToken) + { + var message = new HttpRequestMessage(HttpMethod.Get, requestBuilder.Build()); + var startTime = DateTime.UtcNow; + CompatResult result; + try + { + var response = await client.SendAsync(message, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); + result = await response.Content.ReadAsAsync(formatters, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + PrintError(e); + result = new CompatResult{ReturnCode = -2}; + } + result.RequestBuilder = requestBuilder; + result.RequestDuration = DateTime.UtcNow - startTime; + return result; + } + + public async Task GetUpdateAsync(CancellationToken cancellationToken, string commit = "somecommit") + { + var message = new HttpRequestMessage(HttpMethod.Get, "https://update.rpcs3.net/?c=" + commit); + UpdateInfo result; + try + { + var response = await client.SendAsync(message, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); + result = await response.Content.ReadAsAsync(formatters, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + PrintError(e); + result = new UpdateInfo { ReturnCode = -2 }; + } + return result; + } + + public async Task GetPrInfoAsync(string pr, CancellationToken cancellationToken) + { + if (prInfoCache.TryGetValue(pr, out var result)) + return result; + + var message = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/repos/RPCS3/rpcs3/pulls/" + pr); + HttpContent content = null; + try + { + message.Headers.UserAgent.Add(new ProductInfoHeaderValue("RPCS3CompatibilityBot", "2.0")); + var response = await client.SendAsync(message, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); + content = response.Content; + await content.LoadIntoBufferAsync().ConfigureAwait(false); + result = await content.ReadAsAsync(formatters, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + PrintError(e); + if (content != null) + try { Console.WriteLine(await content.ReadAsStringAsync().ConfigureAwait(false)); } catch {} + int.TryParse(pr, out var prnum); + return new PrInfo{Number = prnum}; + } + + lock (prInfoCache) + { + if (prInfoCache.TryGetValue(pr, out var cachedResult)) + return cachedResult; + + prInfoCache[pr] = result; + return result; + } + } + + private void PrintError(Exception e) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("Error communicating with api: " + e.Message); + Console.ResetColor(); + } + } +} \ No newline at end of file diff --git a/CompatApiClient/CompatApiClient.csproj b/CompatApiClient/CompatApiClient.csproj new file mode 100644 index 00000000..4ac154e1 --- /dev/null +++ b/CompatApiClient/CompatApiClient.csproj @@ -0,0 +1,20 @@ + + + + netstandard2.0 + + + + latest + + + + latest + + + + + + + + diff --git a/CompatApiClient/Compression/CompressedContent.cs b/CompatApiClient/Compression/CompressedContent.cs new file mode 100644 index 00000000..96887888 --- /dev/null +++ b/CompatApiClient/Compression/CompressedContent.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; + +namespace CompatApiClient.Compression +{ + public class CompressedContent : HttpContent + { + private readonly HttpContent content; + private readonly ICompressor compressor; + + public CompressedContent(HttpContent content, ICompressor compressor) + { + if (content == null) + throw new ArgumentNullException(nameof(content)); + + if (compressor == null) + throw new ArgumentNullException(nameof(compressor)); + + this.content = content; + this.compressor = compressor; + AddHeaders(); + } + + protected override bool TryComputeLength(out long length) + { + length = -1; + return false; + } + + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context) + { + if (stream == null) + throw new ArgumentNullException(nameof(stream)); + + using (content) + { + var contentStream = await content.ReadAsStreamAsync().ConfigureAwait(false); + var compressedLength = await compressor.CompressAsync(contentStream, stream).ConfigureAwait(false); + Headers.ContentLength = compressedLength; + } + } + + private void AddHeaders() + { + foreach (var header in content.Headers) + Headers.TryAddWithoutValidation(header.Key, header.Value); + Headers.ContentEncoding.Add(compressor.EncodingType); + Headers.ContentLength = null; + } + } +} \ No newline at end of file diff --git a/CompatApiClient/Compression/CompressionMessageHandler.cs b/CompatApiClient/Compression/CompressionMessageHandler.cs new file mode 100644 index 00000000..456d2761 --- /dev/null +++ b/CompatApiClient/Compression/CompressionMessageHandler.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace CompatApiClient.Compression +{ + public class CompressionMessageHandler : DelegatingHandler + { + public ICollection Compressors { get; } + public static readonly string PostCompressionFlag = "X-Set-Content-Encoding"; + public static readonly string[] DefaultContentEncodings = { "gzip", "deflate" }; + public static readonly string DefaultAcceptEncodings = "gzip, deflate"; + + private bool isServer; + private bool isClient => !isServer; + + public CompressionMessageHandler(bool isServer = false) + { + this.isServer = isServer; + Compressors = new ICompressor[] + { + new GZipCompressor(), + new DeflateCompressor(), + }; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (isServer && request.Content?.Headers?.ContentEncoding != null) + { + var encoding = request.Content.Headers.ContentEncoding.FirstOrDefault(); + if (encoding != null) + { + var compressor = Compressors.FirstOrDefault(c => c.EncodingType.Equals(encoding, StringComparison.InvariantCultureIgnoreCase)); + if (compressor != null) + request.Content = new DecompressedContent(request.Content, compressor); + } + } + if (isClient && (request.Method == HttpMethod.Post || request.Method == HttpMethod.Put) && request.Content != null && request.Headers != null && request.Headers.Contains(PostCompressionFlag)) + { + var encoding = request.Headers.GetValues(PostCompressionFlag).FirstOrDefault(); + if (encoding != null) + { + var compressor = Compressors.FirstOrDefault(c => c.EncodingType.Equals(encoding, StringComparison.InvariantCultureIgnoreCase)); + if (compressor != null) + request.Content = new CompressedContent(request.Content, compressor); + } + } + request.Headers?.Remove(PostCompressionFlag); + var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (isClient && response.Content?.Headers?.ContentEncoding != null) + { + var encoding = response.Content.Headers.ContentEncoding.FirstOrDefault(); + if (encoding != null) + { + var compressor = Compressors.FirstOrDefault(c => c.EncodingType.Equals(encoding, StringComparison.InvariantCultureIgnoreCase)); + if (compressor != null) + response.Content = new DecompressedContent(response.Content, compressor); + } + } + if (isServer && response.Content != null && request.Headers?.AcceptEncoding != null) + { + var encoding = request.Headers.AcceptEncoding.FirstOrDefault(); + if (encoding == null) + return response; + + var compressor = Compressors.FirstOrDefault(c => c.EncodingType.Equals(encoding.Value, StringComparison.InvariantCultureIgnoreCase)); + if (compressor == null) + return response; + + response.Content = new CompressedContent(response.Content, compressor); + } + return response; + } + } +} \ No newline at end of file diff --git a/CompatApiClient/Compression/Compressor.cs b/CompatApiClient/Compression/Compressor.cs new file mode 100644 index 00000000..8984142a --- /dev/null +++ b/CompatApiClient/Compression/Compressor.cs @@ -0,0 +1,36 @@ +using System.IO; +using System.Threading.Tasks; + +namespace CompatApiClient.Compression +{ + public abstract class Compressor : ICompressor + { + public abstract string EncodingType { get; } + public abstract Stream CreateCompressionStream(Stream output); + public abstract Stream CreateDecompressionStream(Stream input); + + public virtual async Task CompressAsync(Stream source, Stream destination) + { + using (var memStream = new MemoryStream()) + { + using (var compressed = CreateCompressionStream(memStream)) + await source.CopyToAsync(compressed).ConfigureAwait(false); + memStream.Seek(0, SeekOrigin.Begin); + await memStream.CopyToAsync(destination).ConfigureAwait(false); + return memStream.Length; + } + } + + public virtual async Task DecompressAsync(Stream source, Stream destination) + { + using (var memStream = new MemoryStream()) + { + using (var decompressed = CreateDecompressionStream(source)) + await decompressed.CopyToAsync(memStream).ConfigureAwait(false); + memStream.Seek(0, SeekOrigin.Begin); + await memStream.CopyToAsync(destination).ConfigureAwait(false); + return memStream.Length; + } + } + } +} \ No newline at end of file diff --git a/CompatApiClient/Compression/DecompressedContent.cs b/CompatApiClient/Compression/DecompressedContent.cs new file mode 100644 index 00000000..5d532bd2 --- /dev/null +++ b/CompatApiClient/Compression/DecompressedContent.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; + +namespace CompatApiClient.Compression +{ + public class DecompressedContent : HttpContent + { + private readonly HttpContent content; + private readonly ICompressor compressor; + + public DecompressedContent(HttpContent content, ICompressor compressor) + { + if (content == null) + throw new ArgumentNullException(nameof(content)); + + if (compressor == null) + throw new ArgumentNullException(nameof(compressor)); + + this.content = content; + this.compressor = compressor; + RemoveHeaders(); + } + + protected override bool TryComputeLength(out long length) + { + length = -1; + return false; + } + + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context) + { + if (stream == null) + throw new ArgumentNullException(nameof(stream)); + + using (content) + { + var contentStream = await content.ReadAsStreamAsync().ConfigureAwait(false); + var decompressedLength = await compressor.DecompressAsync(contentStream, stream).ConfigureAwait(false); + Headers.ContentLength = decompressedLength; + } + } + + private void RemoveHeaders() + { + foreach (var header in content.Headers) + Headers.TryAddWithoutValidation(header.Key, header.Value); + Headers.ContentEncoding.Clear(); + Headers.ContentLength = null; + } + } +} \ No newline at end of file diff --git a/CompatApiClient/Compression/DeflateCompressor.cs b/CompatApiClient/Compression/DeflateCompressor.cs new file mode 100644 index 00000000..a1db49fb --- /dev/null +++ b/CompatApiClient/Compression/DeflateCompressor.cs @@ -0,0 +1,20 @@ +using System.IO; +using System.IO.Compression; + +namespace CompatApiClient.Compression +{ + public class DeflateCompressor : Compressor + { + public override string EncodingType => "deflate"; + + public override Stream CreateCompressionStream(Stream output) + { + return new DeflateStream(output, CompressionMode.Compress, true); + } + + public override Stream CreateDecompressionStream(Stream input) + { + return new DeflateStream(input, CompressionMode.Decompress, true); + } + } +} \ No newline at end of file diff --git a/CompatApiClient/Compression/GZipCompressor.cs b/CompatApiClient/Compression/GZipCompressor.cs new file mode 100644 index 00000000..056959c8 --- /dev/null +++ b/CompatApiClient/Compression/GZipCompressor.cs @@ -0,0 +1,20 @@ +using System.IO; +using System.IO.Compression; + +namespace CompatApiClient.Compression +{ + public class GZipCompressor : Compressor + { + public override string EncodingType => "gzip"; + + public override Stream CreateCompressionStream(Stream output) + { + return new GZipStream(output, CompressionMode.Compress, true); + } + + public override Stream CreateDecompressionStream(Stream input) + { + return new GZipStream(input, CompressionMode.Decompress, true); + } + } +} \ No newline at end of file diff --git a/CompatApiClient/Compression/ICompressor.cs b/CompatApiClient/Compression/ICompressor.cs new file mode 100644 index 00000000..bd9d4d45 --- /dev/null +++ b/CompatApiClient/Compression/ICompressor.cs @@ -0,0 +1,12 @@ +using System.IO; +using System.Threading.Tasks; + +namespace CompatApiClient.Compression +{ + public interface ICompressor + { + string EncodingType { get; } + Task CompressAsync(Stream source, Stream destination); + Task DecompressAsync(Stream source, Stream destination); + } +} \ No newline at end of file diff --git a/CompatApiClient/Formatters/JsonContractResolver.cs b/CompatApiClient/Formatters/JsonContractResolver.cs new file mode 100644 index 00000000..02ee6587 --- /dev/null +++ b/CompatApiClient/Formatters/JsonContractResolver.cs @@ -0,0 +1,32 @@ +using System; +using System.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace CompatApiClient +{ + public class JsonContractResolver : DefaultContractResolver + { + private readonly Func propertyNameResolver; + + public JsonContractResolver() + : this(NamingStyles.Dashed) + { + } + + public JsonContractResolver(Func propertyNameResolver) + { + if (propertyNameResolver == null) + throw new ArgumentNullException(nameof(propertyNameResolver)); + + this.propertyNameResolver = propertyNameResolver; + } + + protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) + { + var property = base.CreateProperty(member, memberSerialization); + property.PropertyName = propertyNameResolver(property.PropertyName); + return property; + } + } +} \ No newline at end of file diff --git a/CompatApiClient/Formatters/NamingStyles.cs b/CompatApiClient/Formatters/NamingStyles.cs new file mode 100644 index 00000000..204876da --- /dev/null +++ b/CompatApiClient/Formatters/NamingStyles.cs @@ -0,0 +1,58 @@ +using System; +using System.Text; + +namespace CompatApiClient +{ + public static class NamingStyles + { + public static string CamelCase(string value) + { + if (value == null) + throw new ArgumentNullException(nameof(value)); + + if (value.Length > 0) + { + if (char.IsUpper(value[0])) + value = char.ToLower(value[0]) + value.Substring(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; + + var hasPrefix = true; + var builder = new StringBuilder(value.Length + 3); + foreach (var c in value) + { + var ch = c; + if (char.IsUpper(ch)) + { + ch = char.ToLower(ch); + if (!hasPrefix) + builder.Append(separator); + hasPrefix = true; + } + else + hasPrefix = false; + builder.Append(ch); + } + return builder.ToString(); + } + } +} \ No newline at end of file diff --git a/CompatApiClient/POCOs/CompatResult.cs b/CompatApiClient/POCOs/CompatResult.cs new file mode 100644 index 00000000..77a5250d --- /dev/null +++ b/CompatApiClient/POCOs/CompatResult.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace CompatApiClient.POCOs +{ + public class CompatResult + { + public int ReturnCode; + public string SearchTerm; + public Dictionary Results; + + [JsonIgnore] + public TimeSpan RequestDuration; + [JsonIgnore] + public RequestBuilder RequestBuilder; + } + + public class TitleInfo + { + public static readonly TitleInfo Maintenance = new TitleInfo { Status = "Maintenance" }; + public static readonly TitleInfo CommunicationError = new TitleInfo { Status = "Error" }; + public static readonly TitleInfo Unknown = new TitleInfo { Status = "Unknown" }; + + public string Title; + public string Status; + public string Date; + public int Thread; + public string Commit; + public int? Pr; + } +} \ No newline at end of file diff --git a/CompatApiClient/POCOs/PrInfo.cs b/CompatApiClient/POCOs/PrInfo.cs new file mode 100644 index 00000000..2cd4bdb4 --- /dev/null +++ b/CompatApiClient/POCOs/PrInfo.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace CompatApiClient.POCOs +{ + public class PrInfo + { + public int Number; + public string Title; + public GithubUser User; + public int Additions; + public int Deletions; + public int ChangedFiles; + } + + public class GithubUser + { + public string login; + } +} diff --git a/CompatApiClient/POCOs/UpdateInfo.cs b/CompatApiClient/POCOs/UpdateInfo.cs new file mode 100644 index 00000000..1036604f --- /dev/null +++ b/CompatApiClient/POCOs/UpdateInfo.cs @@ -0,0 +1,23 @@ +using System; + +namespace CompatApiClient.POCOs +{ + public class UpdateInfo + { + public int ReturnCode; + public BuildInfo LatestBuild; + } + + public class BuildInfo + { + public string Pr; + public BuildLink Windows; + public BuildLink Linux; + } + + public class BuildLink + { + public string Datetime; + public string Download; + } +} \ No newline at end of file diff --git a/CompatApiClient/RequestBuilder.cs b/CompatApiClient/RequestBuilder.cs new file mode 100644 index 00000000..89e642e2 --- /dev/null +++ b/CompatApiClient/RequestBuilder.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace CompatApiClient +{ + public class RequestBuilder + { + public string customHeader { get; private set; } + public string search { get; private set; } + private int? status; + private string start; + private string sort; + private string date; + public char? releaseType { get; private set; } + public char? region { get; private set; } + private int amount = ApiConfig.ResultAmount[0]; + public int amountRequested { get; private set; } = ApiConfig.ResultAmount[0]; + + private RequestBuilder() + { + } + + public static RequestBuilder Start() + { + return new RequestBuilder(); + } + + public RequestBuilder SetSearch(string search) + { + this.search = search; + return this; + } + + public RequestBuilder SetHeader(string header) + { + this.customHeader = header; + return this; + } + + public RequestBuilder SetStatus(string status) + { + if (ApiConfig.Statuses.TryGetValue(status, out var statusCode)) + this.status = statusCode; + return this; + } + + public RequestBuilder SetStartsWith(string prefix) + { + if (prefix == "num" || prefix == "09") + start = "09"; + else if (prefix == "sym" || prefix == "#") + start = "sym"; + else if (prefix?.Length == 1) + start = prefix; + return this; + } + + public RequestBuilder SetSort(string type, string direction) + { + if (ApiConfig.SortTypes.TryGetValue(type, out var sortType) && ApiConfig.ReverseDirections.TryGetValue(direction, out var dir)) + sort = sortType.ToString() + dir; + return this; + } + + public RequestBuilder SetDate(string date) + { + if (DateTime.TryParseExact(date, ApiConfig.DateInputFormat, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal, out var parsedDate)) + this.date = parsedDate.ToString(ApiConfig.DateQueryFormat); + return this; + } + + public RequestBuilder SetReleaseType(string type) + { + if (ApiConfig.ReverseReleaseTypes.TryGetValue(type, out var releaseType)) + this.releaseType = releaseType; + return this; + } + + public RequestBuilder SetRegion(string region) + { + if (ApiConfig.ReverseRegions.TryGetValue(region, out var regionCode)) + this.region = regionCode; + return this; + } + + public RequestBuilder SetAmount(int amount) + { + if (amount < 1) + return this; + + foreach (var bracket in ApiConfig.ResultAmount) + { + if (amount <= bracket) + { + this.amount = bracket; + this.amountRequested = amount; + return this; + } + } + return this; + } + + public Uri Build(bool apiCall = true) + { + var parameters = new Dictionary + { + {"g", search}, + {"s", status?.ToString()}, + {"c", start}, + {"o", sort}, + {"d", date}, + {"t", releaseType?.ToString()}, + {"f", region?.ToString()}, + {"r", amount.ToString()}, + }; + if (apiCall) + parameters["api"] = "v" + ApiConfig.Version; + return ApiConfig.BaseUrl.SetQueryParameters(parameters); + } + } +} \ No newline at end of file diff --git a/CompatApiClient/Utils/UriExtensions.cs b/CompatApiClient/Utils/UriExtensions.cs new file mode 100644 index 00000000..ac287481 --- /dev/null +++ b/CompatApiClient/Utils/UriExtensions.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Net; +using System.Net.Http; +using System.Text; + +namespace CompatApiClient +{ + public static class UriExtensions + { + private static readonly Uri FakeHost = new Uri("sc://q"); // s:// will be parsed as file:///s:// for some reason + + public static NameValueCollection ParseQueryString(Uri uri) + { + if (!uri.IsAbsoluteUri) + uri = new Uri(FakeHost, uri); + return uri.ParseQueryString(); + } + + public static string GetQueryParameter(this Uri uri, string name) + { + var parameters = ParseQueryString(uri); + return parameters[name]; + } + + public static Uri AddQueryParameter(this Uri uri, string name, string value) + { + var queryValue = WebUtility.UrlEncode(name) + "=" + WebUtility.UrlEncode(value); + return AddQueryValue(uri, queryValue); + } + + public static Uri AddQueryParameters(Uri uri, IEnumerable> parameters) + { + var builder = new StringBuilder(); + foreach (var param in parameters) + { + if (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); + parameters[name] = value; + return SetQueryValue(uri, FormatUriParams(parameters)); + } + + public static Uri SetQueryParameters(this Uri uri, IEnumerable> items) + { + var parameters = ParseQueryString(uri); + foreach (var item in items) + parameters[item.Key] = item.Value; + return SetQueryValue(uri, FormatUriParams(parameters)); + } + + public static string FormatUriParams(NameValueCollection parameters) + { + if (parameters == null || parameters.Count == 0) + return ""; + + var result = new StringBuilder(); + foreach (var key in parameters.AllKeys) + { + 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); + } + + private static Uri AddQueryValue(Uri uri, string queryToAppend) + { + var query = uri.IsAbsoluteUri ? uri.Query : new Uri(FakeHost, uri).Query; + if (!string.IsNullOrEmpty(query) && query.Length > 1) + query = query.Substring(1) + "&" + queryToAppend; + else + query = queryToAppend; + return SetQueryValue(uri, query); + } + + private static Uri SetQueryValue(Uri uri, string value) + { + 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().Substring(FakeHost.OriginalString.Length + additionalStrip); + return new Uri(newUri, UriKind.Relative); + } + } + } +} \ No newline at end of file diff --git a/CompatApiClient/Utils/Utils.cs b/CompatApiClient/Utils/Utils.cs new file mode 100644 index 00000000..08afb139 --- /dev/null +++ b/CompatApiClient/Utils/Utils.cs @@ -0,0 +1,44 @@ +using System; + +namespace CompatApiClient +{ + public static class Utils + { + public static string Trim(this string str, int maxLength) + { + const int minSaneLimit = 4; + + if (maxLength < minSaneLimit) + throw new ArgumentException("Argument cannot be less than " + minSaneLimit, nameof(maxLength)); + + if (string.IsNullOrEmpty(str)) + return str; + + if (str.Length > maxLength) + return str.Substring(0, maxLength - 3) + "..."; + + 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 (string.IsNullOrEmpty(str) || str.Length <= maxLength) + return str; + + return str.Substring(0, maxLength); + } + + public static string Sanitize(this string str) + { + return str?.Replace("`", "`\u200d").Replace("@", "@\u200d"); + } + + public static int Clamp(this int amount, int low, int high) + { + return Math.Min(high, Math.Max(amount, low)); + } + } +} diff --git a/CompatBot/Attributes/CheckBaseAttributeWithReactions.cs b/CompatBot/Attributes/CheckBaseAttributeWithReactions.cs new file mode 100644 index 00000000..6e2551e1 --- /dev/null +++ b/CompatBot/Attributes/CheckBaseAttributeWithReactions.cs @@ -0,0 +1,38 @@ +using System.Threading.Tasks; +using DSharpPlus.CommandsNext; +using DSharpPlus.CommandsNext.Attributes; +using DSharpPlus.Entities; + +namespace CompatBot.Attributes +{ + internal abstract class CheckBaseAttributeWithReactions: CheckBaseAttribute + { + protected abstract Task IsAllowed(CommandContext ctx, bool help); + + public DiscordEmoji ReactOnSuccess { get; } + public DiscordEmoji ReactOnFailure { get; } + + public CheckBaseAttributeWithReactions(DiscordEmoji reactOnSuccess = null, DiscordEmoji reactOnFailure = null) + { + ReactOnSuccess = reactOnSuccess; + ReactOnFailure = reactOnFailure; + } + + public override async Task ExecuteCheckAsync(CommandContext ctx, bool help) + { + var result = await IsAllowed(ctx, help); + //await ctx.RespondAsync($"Check for {GetType().Name} resulted in {result}").ConfigureAwait(false); + if (result) + { + if (ReactOnSuccess != null && !help) + await ctx.Message.CreateReactionAsync(ReactOnSuccess).ConfigureAwait(false); + } + else + { + if (ReactOnFailure != null && !help) + await ctx.Message.CreateReactionAsync(ReactOnFailure).ConfigureAwait(false); + } + return result; + } + } +} \ No newline at end of file diff --git a/CompatBot/Attributes/RequiresBotModRole.cs b/CompatBot/Attributes/RequiresBotModRole.cs new file mode 100644 index 00000000..1183eb2c --- /dev/null +++ b/CompatBot/Attributes/RequiresBotModRole.cs @@ -0,0 +1,18 @@ +using System; +using System.Threading.Tasks; +using CompatBot.Providers; +using DSharpPlus.CommandsNext; + +namespace CompatBot.Attributes +{ + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] + internal class RequiresBotModRole: CheckBaseAttributeWithReactions + { + public RequiresBotModRole() : base(reactOnFailure: Config.Reactions.Denied) { } + + protected override Task IsAllowed(CommandContext ctx, bool help) + { + return Task.FromResult(ModProvider.IsMod(ctx.User.Id)); + } + } +} diff --git a/CompatBot/Attributes/RequiresBotSudoerRole.cs b/CompatBot/Attributes/RequiresBotSudoerRole.cs new file mode 100644 index 00000000..704d1d39 --- /dev/null +++ b/CompatBot/Attributes/RequiresBotSudoerRole.cs @@ -0,0 +1,18 @@ +using System; +using System.Threading.Tasks; +using CompatBot.Providers; +using DSharpPlus.CommandsNext; + +namespace CompatBot.Attributes +{ + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] + internal class RequiresBotSudoerRole: CheckBaseAttributeWithReactions + { + public RequiresBotSudoerRole(): base(reactOnFailure: Config.Reactions.Denied) { } + + protected override Task IsAllowed(CommandContext ctx, bool help) + { + return Task.FromResult(ModProvider.IsSudoer(ctx.User.Id)); + } + } +} \ No newline at end of file diff --git a/CompatBot/Attributes/RequiresDm.cs b/CompatBot/Attributes/RequiresDm.cs new file mode 100644 index 00000000..03bf06c3 --- /dev/null +++ b/CompatBot/Attributes/RequiresDm.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading.Tasks; +using DSharpPlus.CommandsNext; +using DSharpPlus.CommandsNext.Attributes; + +namespace CompatBot.Attributes +{ + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] + internal class RequiresDm: CheckBaseAttribute + { + public override async Task ExecuteCheckAsync(CommandContext ctx, bool help) + { + if (ctx.Channel.IsPrivate || help) + return true; + + await ctx.RespondAsync($"{ctx.Message.Author.Mention} https://i.imgflip.com/24qx11.jpg").ConfigureAwait(false); + return false; + } + } +} \ No newline at end of file diff --git a/CompatBot/Commands/Antipiracy.cs b/CompatBot/Commands/Antipiracy.cs new file mode 100644 index 00000000..927cb97d --- /dev/null +++ b/CompatBot/Commands/Antipiracy.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using CompatBot.Attributes; +using CompatBot.Database; +using CompatBot.Providers; +using CompatBot.Utils; +using DSharpPlus.CommandsNext; +using DSharpPlus.CommandsNext.Attributes; +using DSharpPlus.Entities; +using Microsoft.EntityFrameworkCore; + +namespace CompatBot.Commands +{ + [Group("piracy"), RequiresBotModRole, RequiresDm] + [Description("Used to manage piracy filters **in DM**")] + internal sealed class Antipiracy: BaseCommandModule + { + [Command("list"), Aliases("show")] + [Description("Lists all filters")] + public async Task List(CommandContext ctx) + { + var typingTask = ctx.TriggerTypingAsync(); + var result = new StringBuilder("```") + .AppendLine("ID | Trigger") + .AppendLine("-----------------------------"); + foreach (var item in await BotDb.Instance.Piracystring.ToListAsync().ConfigureAwait(false)) + result.AppendLine($"{item.Id:0000} | {item.String}"); + await ctx.SendAutosplitMessageAsync(result.Append("```")).ConfigureAwait(false); + await typingTask; + } + + [Command("add")] + [Description("Adds a new piracy filter trigger")] + public async Task Add(CommandContext ctx, [RemainingText, Description("A plain string to match")] string trigger) + { + var typingTask = ctx.TriggerTypingAsync(); + var wasSuccessful = await PiracyStringProvider.AddAsync(trigger).ConfigureAwait(false); + (DiscordEmoji reaction, string msg) result = wasSuccessful + ? (Config.Reactions.Success, "New trigger successfully saved!") + : (Config.Reactions.Failure, "Trigger already defined."); + await Task.WhenAll( + ctx.RespondAsync(result.msg), + ctx.Message.CreateReactionAsync(result.reaction), + typingTask + ).ConfigureAwait(false); + if (wasSuccessful) + await List(ctx).ConfigureAwait(false); + } + + [Command("remove"), Aliases("delete", "del")] + [Description("Removes a piracy filter trigger")] + public async Task Remove(CommandContext ctx, [Description("Filter ids to remove separated with spaces")] params int[] ids) + { + var typingTask = ctx.TriggerTypingAsync(); + (DiscordEmoji reaction, string msg) result = (Config.Reactions.Success, $"Trigger{(ids.Length == 1 ? "" : "s")} successfully removed!"); + var failedIds = new List(); + foreach (var id in ids) + if (!await PiracyStringProvider.RemoveAsync(id).ConfigureAwait(false)) + failedIds.Add(id); + if (failedIds.Count > 0) + result = (Config.Reactions.Failure, "Some ids couldn't be removed: " + string.Join(", ", failedIds)); + await Task.WhenAll( + ctx.RespondAsync(result.msg), + ctx.Message.CreateReactionAsync(result.reaction), + typingTask + ).ConfigureAwait(false); + await List(ctx).ConfigureAwait(false); + } + } +} diff --git a/CompatBot/Commands/CompatList.cs b/CompatBot/Commands/CompatList.cs new file mode 100644 index 00000000..96ecf8e3 --- /dev/null +++ b/CompatBot/Commands/CompatList.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CompatApiClient; +using CompatApiClient.POCOs; +using CompatBot.ResultFormatters; +using CompatBot.Utils; +using DSharpPlus; +using DSharpPlus.CommandsNext; +using DSharpPlus.CommandsNext.Attributes; +using DSharpPlus.Entities; + +namespace CompatBot.Commands +{ + internal sealed class CompatList : BaseCommandModule + { + private static readonly Client client = new Client(); + + [Command("compat"), Aliases("c")] + [Description("Searches the compatibility database, USE: !compat searchterm")] + public async Task Compat(CommandContext ctx, [RemainingText, Description("Game title to look up")] string title) + { + try + { + var requestBuilder = RequestBuilder.Start().SetSearch(title?.Truncate(40)); + await DoRequestAndRespond(ctx, requestBuilder).ConfigureAwait(false); + } + catch (Exception e) + { + ctx.Client.DebugLogger.LogMessage(LogLevel.Error, "asdf", e.Message, DateTime.Now); + } + } + + [Command("top")] + [Description(@" +Gets the x (default is 10 new) top games by specified criteria; order is flexible +Example usage: + !top 10 new + !top 10 new jpn + !top 10 playable + !top 10 new ingame eu + !top 10 old psn intro + !top 10 old loadable us bluray")] + public async Task Top(CommandContext ctx, [Description("To see all filters do !filters")] params string[] filters) + { + var requestBuilder = RequestBuilder.Start(); + var age = "new"; + var amount = 10; + foreach (var term in filters.Select(s => s.ToLowerInvariant())) + { + switch (term) + { + case "old": case "new": + age = term; + break; + case string status when ApiConfig.Statuses.ContainsKey(status): + requestBuilder.SetStatus(status); + break; + case string rel when ApiConfig.ReverseReleaseTypes.ContainsKey(rel): + requestBuilder.SetReleaseType(rel); + break; + case string reg when ApiConfig.ReverseRegions.ContainsKey(reg): + requestBuilder.SetRegion(reg); + break; + case string num when int.TryParse(num, out var newAmount): + amount = newAmount.Clamp(1, Config.TopLimit); + break; + } + } + requestBuilder.SetAmount(amount); + if (age == "old") + { + requestBuilder.SetSort("date", "asc"); + requestBuilder.SetHeader("{0} requested top {1} oldest {2} {3} updated games"); + } + else + { + requestBuilder.SetSort("date", "desc"); + requestBuilder.SetHeader("{0} requested top {1} newest {2} {3} updated games"); + } + await DoRequestAndRespond(ctx, requestBuilder).ConfigureAwait(false); + } + + [Command("filters")] + [Description("Provides information about available filters for the !top command")] + public async Task Filters(CommandContext ctx) + { + var getDmTask = ctx.CreateDmAsync(); + if (ctx.Channel.IsPrivate) + await ctx.TriggerTypingAsync().ConfigureAwait(false); + var embed = new DiscordEmbedBuilder {Description = "List of recognized tokens in each filter category", Color = Config.Colors.Help} + .AddField("Regions", DicToDesc(ApiConfig.Regions)) + .AddField("Statuses", DicToDesc(ApiConfig.Statuses)) + .AddField("Release types", DicToDesc(ApiConfig.ReleaseTypes)) + .Build(); + var dm = await getDmTask.ConfigureAwait(false); + await dm.SendMessageAsync(embed: embed).ConfigureAwait(false); + } + + [Command("latest"), Aliases("download")] + [Description("Provides links to the latest RPCS3 build")] + public async Task Latest(CommandContext ctx) + { + var getDmTask = ctx.CreateDmAsync(); + if (ctx.Channel.IsPrivate) + await ctx.TriggerTypingAsync().ConfigureAwait(false); + var info = await client.GetUpdateAsync(Config.Cts.Token).ConfigureAwait(false); + var embed = await info.AsEmbedAsync().ConfigureAwait(false); + var dm = await getDmTask.ConfigureAwait(false); + await dm.SendMessageAsync(embed: embed.Build()).ConfigureAwait(false); + } + + private static string DicToDesc(Dictionary dictionary) + { + var result = new StringBuilder(); + foreach (var lst in dictionary.Values) + result.AppendLine(string.Join(", ", lst.Reverse())); + return result.ToString(); + } + + private static string DicToDesc(Dictionary dictionary) + { + return string.Join(", ", dictionary.Keys); + } + + private async Task DoRequestAndRespond(CommandContext ctx, RequestBuilder requestBuilder) + { + var botChannelTask = ctx.Client.GetChannelAsync(Config.BotChannelId); + Console.WriteLine(requestBuilder.Build()); + var result = await client.GetCompatResultAsync(requestBuilder, Config.Cts.Token).ConfigureAwait(false); + var botChannel = await botChannelTask.ConfigureAwait(false); + foreach (var msg in FormatSearchResults(ctx, result)) + await botChannel.SendAutosplitMessageAsync(msg).ConfigureAwait(false); + } + + private IEnumerable FormatSearchResults(CommandContext ctx, CompatResult compatResult) + { + var returnCode = ApiConfig.ReturnCodes[compatResult.ReturnCode]; + var request = compatResult.RequestBuilder; + + if (returnCode.overrideAll) + yield return string.Format(returnCode.info, ctx.Message.Author.Mention); + else + { + var result = new StringBuilder(); + if (string.IsNullOrEmpty(request.customHeader)) + result.AppendLine($"{ctx.Message.Author.Mention} searched for: ***{request.search.Sanitize()}***"); + else + { + string[] region = null, media = null; + if (request.region.HasValue) ApiConfig.Regions.TryGetValue(request.region.Value, out region); + if (request.releaseType.HasValue) ApiConfig.Regions.TryGetValue(request.releaseType.Value, out media); + var formattedHeader = string.Format(request.customHeader, ctx.Message.Author.Mention, request.amountRequested, region?.Last(), media?.Last()); + result.AppendLine(formattedHeader.Replace(" ", " ").Replace(" ", " ")); + } + result.AppendFormat(returnCode.info, compatResult.SearchTerm); + yield return result.ToString(); + result.Clear(); + var footer = $"Retrieved from: *<{request.Build(false).ToString().Replace(' ', '+')}>* in {compatResult.RequestDuration.TotalMilliseconds:0} milliseconds!"; + + if (returnCode.displayResults) + { + result.Append("```"); + foreach (var resultInfo in compatResult.Results.Take(request.amountRequested)) + { + var info = resultInfo.AsString(); + result.AppendLine(info); + } + result.Append("```"); + yield return result.ToString(); + yield return footer; + } + else if (returnCode.displayFooter) + yield return footer; + } + } + } +} \ No newline at end of file diff --git a/CompatBot/Commands/Explain.cs b/CompatBot/Commands/Explain.cs new file mode 100644 index 00000000..5e2a7dbd --- /dev/null +++ b/CompatBot/Commands/Explain.cs @@ -0,0 +1,194 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using CompatBot.Attributes; +using CompatBot.Database; +using CompatBot.Utils; +using DSharpPlus; +using DSharpPlus.CommandsNext; +using DSharpPlus.CommandsNext.Attributes; +using DSharpPlus.CommandsNext.Converters; +using DSharpPlus.Entities; +using Microsoft.EntityFrameworkCore; + +namespace CompatBot.Commands +{ + [Group("explain"), Aliases("botsplain", "define")] + //[Cooldown(1, 1, CooldownBucketType.Channel)] + [Description("Used to manage and show explanations")] + internal sealed class Explain: BaseCommandModule + { + [GroupCommand] + public async Task ShowExplanation(CommandContext ctx, [RemainingText, Description("Term to explain")] string term) + { + await ctx.TriggerTypingAsync().ConfigureAwait(false); + if (string.IsNullOrEmpty(term)) + { + await ctx.RespondAsync($"You may want to look at available terms by using `{Config.CommandPrefix}explain list`").ConfigureAwait(false); + return; + } + + var explanation = await BotDb.Instance.Explanation.FirstOrDefaultAsync(e => e.Keyword == term).ConfigureAwait(false); + if (explanation != null) + { + await ctx.RespondAsync(explanation.Text).ConfigureAwait(false); + return; + } + + term = term.StripQuotes(); + var idx = term.LastIndexOf(" to ", StringComparison.InvariantCultureIgnoreCase); + if (idx > 0) + { + var potentialUserId = term.Substring(idx + 4).Trim(); + bool hasMention = false; + try + { + var lookup = await new DiscordUserConverter().ConvertAsync(potentialUserId, ctx).ConfigureAwait(false); + hasMention = lookup.HasValue; + } + catch { } + if (hasMention) + { + term = term.Substring(0, idx).TrimEnd(); + explanation = await BotDb.Instance.Explanation.FirstOrDefaultAsync(e => e.Keyword == term).ConfigureAwait(false); + if (explanation != null) + { + await ctx.RespondAsync(explanation.Text).ConfigureAwait(false); + return; + } + } + } + + await ctx.Message.CreateReactionAsync(Config.Reactions.Failure).ConfigureAwait(false); + } + + [Command("add")] + [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")] string explanation) + { + term = term.StripQuotes(); + if (string.IsNullOrEmpty(explanation)) + { + await Task.WhenAll( + ctx.Message.CreateReactionAsync(Config.Reactions.Failure), + ctx.RespondAsync("An explanation for the term must be provided") + ).ConfigureAwait(false); + } + else if (await BotDb.Instance.Explanation.AnyAsync(e => e.Keyword == term).ConfigureAwait(false)) + await Task.WhenAll( + ctx.Message.CreateReactionAsync(Config.Reactions.Failure), + ctx.RespondAsync($"'{term}' is already defined. Use `update` to update an existing term.") + ).ConfigureAwait(false); + else + { + await BotDb.Instance.Explanation.AddAsync(new Explanation {Keyword = term, Text = explanation}).ConfigureAwait(false); + await BotDb.Instance.SaveChangesAsync().ConfigureAwait(false); + await ctx.Message.CreateReactionAsync(Config.Reactions.Success).ConfigureAwait(false); + } + } + + [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.StripQuotes(); + var item = await BotDb.Instance.Explanation.FirstOrDefaultAsync(e => e.Keyword == term).ConfigureAwait(false); + if (item == null) + { + await Task.WhenAll( + ctx.Message.CreateReactionAsync(Config.Reactions.Failure), + ctx.RespondAsync($"Term '{term}' is not defined") + ).ConfigureAwait(false); + } + else + { + item.Text = explanation; + await BotDb.Instance.SaveChangesAsync().ConfigureAwait(false); + await ctx.Message.CreateReactionAsync(Config.Reactions.Success).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.StripQuotes(); + newTerm = newTerm.StripQuotes(); + var item = await BotDb.Instance.Explanation.FirstOrDefaultAsync(e => e.Keyword == oldTerm).ConfigureAwait(false); + if (item == null) + { + await Task.WhenAll( + ctx.Message.CreateReactionAsync(Config.Reactions.Failure), + ctx.RespondAsync($"Term '{oldTerm}' is not defined") + ).ConfigureAwait(false); + } + else + { + item.Keyword = newTerm; + await BotDb.Instance.SaveChangesAsync().ConfigureAwait(false); + await ctx.Message.CreateReactionAsync(Config.Reactions.Success).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.Message.CreateReactionAsync(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) + { + await ctx.TriggerTypingAsync().ConfigureAwait(false); + var keywords = await BotDb.Instance.Explanation.Select(e => e.Keyword).OrderBy(t => t).ToListAsync().ConfigureAwait(false); + if (keywords.Count == 0) + await ctx.RespondAsync("Nothing has been defined yet").ConfigureAwait(false); + else + try + { + foreach (var embed in new EmbedPager().BreakInEmbeds(new DiscordEmbedBuilder {Title = "Defined terms", Color = Config.Colors.Help}, keywords)) + await ctx.RespondAsync(embed: embed).ConfigureAwait(false); + } + catch (Exception e) + { + ctx.Client.DebugLogger.LogMessage(LogLevel.Error, "", e.ToString(), DateTime.Now); + } + } + + + [Command("remove"), Aliases("delete", "del"), RequiresBotModRole] + [Description("Removes an explanation from the definition list")] + public async Task Remove(CommandContext ctx, [RemainingText, Description("Term to remove")] string term) + { + term = term.StripQuotes(); + var item = await BotDb.Instance.Explanation.FirstOrDefaultAsync(e => e.Keyword == term).ConfigureAwait(false); + if (item == null) + { + await Task.WhenAll( + ctx.Message.CreateReactionAsync(Config.Reactions.Failure), + ctx.RespondAsync($"Term '{term}' is not defined") + ).ConfigureAwait(false); + } + else + { + BotDb.Instance.Explanation.Remove(item); + await BotDb.Instance.SaveChangesAsync().ConfigureAwait(false); + await ctx.Message.CreateReactionAsync(Config.Reactions.Success).ConfigureAwait(false); + } + } + + } +} diff --git a/CompatBot/Commands/Misc.cs b/CompatBot/Commands/Misc.cs new file mode 100644 index 00000000..af248680 --- /dev/null +++ b/CompatBot/Commands/Misc.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using DSharpPlus; +using DSharpPlus.CommandsNext; +using DSharpPlus.CommandsNext.Attributes; +using DSharpPlus.Entities; +using org.mariuszgromada.math.mxparser; + +namespace CompatBot.Commands +{ + internal sealed class Misc: BaseCommandModule + { + private readonly Random rng = new Random(); + + private static readonly List EightBallAnswers = new List { + "Nah mate", "Ya fo sho", "Fo shizzle mah nizzle", "Yuuuup", "Nope", "Njet", "Da", "Maybe", "I don't know", + "I don't care", "Affirmative", "Sure", "Yeah, why not", "Most likely", "Sim", "Oui", "Heck yeah!", "Roger that", + "Aye!", "Yes without a doubt m8!", "Who cares", "Maybe yes, maybe not", "Maybe not, maybe yes", "Ugh", + "Probably", "Ask again later", "Error 404: answer not found", "Don't ask me that again", + "You should think twice before asking", "You what now?", "Bloody hell, answering that ain't so easy", + "Of course not", "Seriously no", "Noooooooooo", "Most likely not", "Não", "Non", "Hell no", "Absolutely not", + "Ask Neko", "Ask Ani", "I'm pretty sure that's illegal!", "<:cell_ok_hand:324618647857397760>", + "Don't be an idiot. YES.", "What do *you* think?", "Only on Wednesdays", "Look in the mirror, you know the answer already" + }; + + private static readonly List RateAnswers = new List + { + "Bad", "Very bad", "Pretty bad", "Horrible", "Ugly", "Disgusting", "Literally the worst", + "Not interesting", "Simply ugh", "I don't like it! You shouldn't either!", "Just like you, 💩", + "Not approved", "Big Mistake", "Ask MsLow", "The opposite of good", + "Could be better", "Could be worse", "Not so bad", + "I likesss!", "Pretty good", "Guchi gud", "Amazing!", "Glorious!", "Very good", "Excellent...", + "Magnificent", "Rate bot says he likes, so you like too", + "If you reorganize the words it says \"pretty cool\"", "I approve", + "I need more time to think about it", "It's ok, nothing and no one is perfect", + "<:morgana_sparkle:315899996274688001> やるじゃねーか!", "Not half bad 👍", "🆗", "😐", "🤮", "Belissimo!", + "So-so" + }; + + [Command("credits"), Aliases("about")] + [Description("Author Credit")] + public async Task Credits(CommandContext ctx) + { + var embed = new DiscordEmbedBuilder + { + Title = "RPCS3 Compatibility Bot", + Url = "https://github.com/RPCS3/discord-bot", + Description = "Made by:\n" + + "\tRoberto Anić Banić aka nicba1010\n" + + "\t13xforever", + Color = DiscordColor.Purple, + }; + await ctx.RespondAsync(embed: embed.Build()); + } + + [Command("math")] + [Description("Math, here you go Juhn")] + public async Task Math(CommandContext ctx, [RemainingText, Description("Math expression")] string expression) + { + var typing = ctx.TriggerTypingAsync(); + var result = @"Something went wrong ¯\\_(ツ)\_/¯" + "\nMath is hard, yo"; + try + { + var expr = new Expression(expression); + result = expr.calculate().ToString(); + } + catch (Exception e) + { + ctx.Client.DebugLogger.LogMessage(LogLevel.Warning, "", "Math failed: " + e.Message, DateTime.Now); + } + await ctx.RespondAsync(result).ConfigureAwait(false); + await typing.ConfigureAwait(false); + } + + [Command("roll")] + [Description("Generates a random number between 1 and N (default 10). Can also roll dices like `2d6`")] + public async Task Roll(CommandContext ctx, [Description("Some positive number or a dice")] string something) + { + var result = ""; + switch (something) + { + case string val when int.TryParse(val, out var maxValue) && maxValue > 1: + lock (rng) result = (rng.Next(maxValue) + 1).ToString(); + break; + case string dice when Regex.IsMatch(dice, @"\d+d\d+"): + var typingTask = ctx.TriggerTypingAsync(); + var diceParts = dice.Split('d', StringSplitOptions.RemoveEmptyEntries); + if (int.TryParse(diceParts[0], out var num) && int.TryParse(diceParts[1], out var face) && + 0 < num && num < 101 && + 1 < face && face < 1001) + { + List rolls; + lock (rng) rolls = Enumerable.Range(0, num).Select(_ => rng.Next(face) + 1).ToList(); + result = "Total: " + rolls.Sum(); + if (rolls.Count > 1) + result += "\nRolls: " + string.Join(' ', rolls); + } + await typingTask.ConfigureAwait(false); + break; + } + if (string.IsNullOrEmpty(result)) + await ctx.Message.CreateReactionAsync(DiscordEmoji.FromUnicode("💩")).ConfigureAwait(false); + else + await ctx.RespondAsync(result).ConfigureAwait(false); + } + + [Command("8ball")] + [Description("Generates a ~~random~~ perfect answer to your question")] + public async Task EightBall(CommandContext ctx, [RemainingText, Description("A yes/no question")] string question) + { + string answer; + lock (rng) answer = EightBallAnswers[rng.Next(EightBallAnswers.Count)]; + await ctx.RespondAsync(answer).ConfigureAwait(false); + } + + [Command("rate")] + [Description("Gives an ~~unrelated~~ expert judgement on the matter at hand")] + public async Task Rate(CommandContext ctx, [RemainingText, Description("Something to rate")] string whatever) + { + string answer; + lock (rng) answer = RateAnswers[rng.Next(RateAnswers.Count)]; + await ctx.RespondAsync(answer).ConfigureAwait(false); + } + } +} diff --git a/CompatBot/Commands/Sudo.Bot.cs b/CompatBot/Commands/Sudo.Bot.cs new file mode 100644 index 00000000..39711d44 --- /dev/null +++ b/CompatBot/Commands/Sudo.Bot.cs @@ -0,0 +1,111 @@ +using System; +using System.Diagnostics; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using DSharpPlus.CommandsNext; +using DSharpPlus.CommandsNext.Attributes; + +namespace CompatBot.Commands +{ + internal partial class Sudo + { + [Group("bot")] + [Description("Commands to manage the bot instance")] + public class Bot: BaseCommandModule + { + + [Command("version")] + [Description("Returns currently checked out bot commit")] + public async Task Version(CommandContext ctx) + { + var typingTask = ctx.TriggerTypingAsync(); + using (var git = new Process + { + StartInfo = new ProcessStartInfo("git", "log -1 --oneline") + { + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + StandardOutputEncoding = Encoding.UTF8, + }, + }) + { + git.Start(); + var stdout = await git.StandardOutput.ReadToEndAsync().ConfigureAwait(false); + git.WaitForExit(); + if (!string.IsNullOrEmpty(stdout)) + await ctx.RespondAsync("```" + stdout + "```").ConfigureAwait(false); + } + await typingTask.ConfigureAwait(false); + } + + [Command("restart"), Aliases("update")] + [Description("Restarts bot and pulls newest commit")] + public async Task Restart(CommandContext ctx) + { + var typingTask = ctx.TriggerTypingAsync(); + if (Monitor.TryEnter(updateObj)) + { + try + { + using (var git = new Process + { + StartInfo = new ProcessStartInfo("git", "pull") + { + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + StandardOutputEncoding = Encoding.UTF8, + }, + }) + { + git.Start(); + var stdout = await git.StandardOutput.ReadToEndAsync().ConfigureAwait(false); + git.WaitForExit(); + if (!string.IsNullOrEmpty(stdout)) + await ctx.RespondAsync("```" + stdout + "```").ConfigureAwait(false); + } + await ctx.RespondAsync("Restarting...").ConfigureAwait(false); + using (var self = new Process + { +#if DEBUG + StartInfo = new ProcessStartInfo("dotnet", $"run -- {Config.Token} {ctx.Channel.Id}") +#else + StartInfo = new ProcessStartInfo("dotnet", $"run -c Release -- {Config.Token} {ctx.Channel.Id}") +#endif + }) + { + self.Start(); + Config.Cts.Cancel(); + return; + } + } + catch (Exception e) + { + await ctx.RespondAsync("Updating failed: " + e.Message).ConfigureAwait(false); + } + finally + { + Monitor.Exit(updateObj); + } + } + else + await ctx.RespondAsync("Update is already in progress").ConfigureAwait(false); + await typingTask.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.RespondAsync(ctx.Channel.IsPrivate + ? $"Shutting down bot instance on {Environment.MachineName}..." + : "Shutting down the bot..." + ).ConfigureAwait(false); + Config.Cts.Cancel(); + } + + } + } +} diff --git a/CompatBot/Commands/Sudo.Mod.cs b/CompatBot/Commands/Sudo.Mod.cs new file mode 100644 index 00000000..ac557a64 --- /dev/null +++ b/CompatBot/Commands/Sudo.Mod.cs @@ -0,0 +1,118 @@ +using System.Text; +using System.Threading.Tasks; +using CompatBot.Providers; +using CompatBot.Utils; +using DSharpPlus.CommandsNext; +using DSharpPlus.CommandsNext.Attributes; +using DSharpPlus.Entities; + +namespace CompatBot.Commands +{ + internal partial class Sudo + { + [Group("mod")] + [Description("Used to manage bot moderators")] + public class Mod : BaseCommandModule + { + [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) + { + var typingTask = ctx.TriggerTypingAsync(); + (DiscordEmoji reaction, string msg) result = + await ModProvider.AddAsync(user.Id).ConfigureAwait(false) + ? (Config.Reactions.Success, $"{user.Mention} was successfully added as moderator, you now have access to editing the piracy trigger list and other useful things! " + + "I will send you the available commands to your message box!") + : (Config.Reactions.Failure, $"{user.Mention} is already a moderator"); + await Task.WhenAll( + ctx.Message.CreateReactionAsync(result.reaction), + ctx.RespondAsync(result.msg), + typingTask + ).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) + { + var typingTask = ctx.TriggerTypingAsync(); + (DiscordEmoji reaction, string msg) result; + if (user.Id == Config.BotAdminId) + { + result = (Config.Reactions.Denied, $"{ctx.Message.Author.Mention} why would you even try this?! Alerting {user.Mention}"); + 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); + } + else if (await ModProvider.RemoveAsync(user.Id).ConfigureAwait(false)) + result = (Config.Reactions.Success, $"{user.Mention} removed as moderator!"); + else + result = (Config.Reactions.Failure, $"{user.Mention} is not a moderator"); + await Task.WhenAll( + ctx.Message.CreateReactionAsync(result.reaction), + ctx.RespondAsync(result.msg), + typingTask + ).ConfigureAwait(false); + } + + [Command("list"), Aliases("show")] + [Description("Lists all moderators")] + public async Task List(CommandContext ctx) + { + var typingTask = ctx.TriggerTypingAsync(); + var list = new StringBuilder("```"); + foreach (var mod in ModProvider.Mods.Values) + list.AppendLine($"{await ctx.GetUserNameAsync(mod.DiscordId),-32} | {(mod.Sudoer ? "sudo" : "not sudo")}"); + await ctx.SendAutosplitMessageAsync(list.Append("```")).ConfigureAwait(false); + await typingTask.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) + { + var typingTask = ctx.TriggerTypingAsync(); + (DiscordEmoji reaction, string msg) result; + if (ModProvider.IsMod(moderator.Id)) + { + result = await ModProvider.MakeSudoerAsync(moderator.Id).ConfigureAwait(false) + ? (Config.Reactions.Success, $"{moderator.Mention} is now a sudoer") + : (Config.Reactions.Failure, $"{moderator.Mention} is already a sudoer"); + } + else + result = (Config.Reactions.Failure, $"{moderator.Mention} is not a moderator (yet)"); + await Task.WhenAll( + ctx.Message.CreateReactionAsync(result.reaction), + ctx.RespondAsync(result.msg), + typingTask + ).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) + { + var typingTask = ctx.TriggerTypingAsync(); + (DiscordEmoji reaction, string msg) result; + if (sudoer.Id == Config.BotAdminId) + { + result = (Config.Reactions.Denied, $"{ctx.Message.Author.Mention} why would you even try this?! Alerting {sudoer.Mention}"); + 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 sudoer permissions ¯\_(ツ)_/¯").ConfigureAwait(false); + } + else if (ModProvider.IsMod(sudoer.Id)) + { + result = await ModProvider.UnmakeSudoerAsync(sudoer.Id).ConfigureAwait(false) + ? (Config.Reactions.Success, $"{sudoer.Mention} is no longer a sudoer") + : (Config.Reactions.Failure, $"{sudoer.Mention} is not a sudoer"); + } + else + result = (Config.Reactions.Failure, $"{sudoer.Mention} is not even a moderator!"); + await Task.WhenAll( + ctx.Message.CreateReactionAsync(result.reaction), + ctx.RespondAsync(result.msg), + typingTask + ).ConfigureAwait(false); + } + } + } +} diff --git a/CompatBot/Commands/Sudo.cs b/CompatBot/Commands/Sudo.cs new file mode 100644 index 00000000..da94d036 --- /dev/null +++ b/CompatBot/Commands/Sudo.cs @@ -0,0 +1,33 @@ +using System.Threading.Tasks; +using CompatBot.Attributes; +using DSharpPlus.CommandsNext; +using DSharpPlus.CommandsNext.Attributes; +using DSharpPlus.Entities; + +namespace CompatBot.Commands +{ + [Group("sudo"), RequiresBotSudoerRole] + [Description("Used to manage bot moderators and sudoers")] + internal sealed partial class Sudo : BaseCommandModule + { + private static readonly object updateObj = new object(); + + [Command("say"), Priority(10)] + [Description("Make bot say things, optionally in a specific channel")] + public async Task Say(CommandContext ctx, [Description("Discord channel (can use just #name in DM)")] DiscordChannel channel, [RemainingText, Description("Message text to send")] string message) + { + var typingTask = channel.TriggerTypingAsync(); + // simulate bot typing the message at 300 cps + await Task.Delay(message.Length * 10 / 3).ConfigureAwait(false); + await channel.SendMessageAsync(message).ConfigureAwait(false); + await typingTask.ConfigureAwait(false); + } + + [Command("say"), Priority(10)] + [Description("Make bot say things, optionally in a specific channel")] + public Task Say(CommandContext ctx, [RemainingText, Description("Message text to send")] string message) + { + return Say(ctx, ctx.Channel, message); + } + } +} diff --git a/CompatBot/Commands/Warnings.ListGroup.cs b/CompatBot/Commands/Warnings.ListGroup.cs new file mode 100644 index 00000000..3180b07f --- /dev/null +++ b/CompatBot/Commands/Warnings.ListGroup.cs @@ -0,0 +1,118 @@ +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CompatApiClient; +using CompatBot.Attributes; +using CompatBot.Database; +using CompatBot.Providers; +using CompatBot.Utils; +using DSharpPlus.CommandsNext; +using DSharpPlus.CommandsNext.Attributes; +using DSharpPlus.Entities; + +namespace CompatBot.Commands +{ + 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 : BaseCommandModule + { + [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 warn")] DiscordUser user) + { + var typingTask = ctx.TriggerTypingAsync(); + if (await CheckListPermissionAsync(ctx, user.Id).ConfigureAwait(false)) + await ListUserWarningsAsync(ctx.Client, ctx.Message, user.Id, user.Username.Sanitize(), false); + await typingTask.ConfigureAwait(false); + } + + [GroupCommand] + public async Task List(CommandContext ctx, [Description("Id of the user to warn")] ulong userId) + { + var typingTask = ctx.TriggerTypingAsync(); + if (await CheckListPermissionAsync(ctx, userId).ConfigureAwait(false)) + await ListUserWarningsAsync(ctx.Client, ctx.Message, userId, $"<@{userId}>", false); + await typingTask.ConfigureAwait(false); + } + + [GroupCommand] + [Description("Show your own warning list")] + public async Task List(CommandContext ctx) + { + var typingTask = ctx.TriggerTypingAsync(); + await List(ctx, ctx.Message.Author).ConfigureAwait(false); + await typingTask.ConfigureAwait(false); + } + + [Command("users"), RequiresBotModRole] + [Description("List users with warnings, sorted from most warned to least")] + public async Task Users(CommandContext ctx) + { + await ctx.TriggerTypingAsync().ConfigureAwait(false); + var userIdColumn = ctx.Channel.IsPrivate ? $"{"User ID",-18} | " : ""; + var header = $"{"User",-25} | {userIdColumn}Count"; + var result = new StringBuilder("Warning count per user:").AppendLine("```") + .AppendLine(header) + .AppendLine("".PadLeft(header.Length, '-')); + var query = from warn in BotDb.Instance.Warning + group warn by warn.DiscordId into userGroup + let row = new { discordId = userGroup.Key, count = userGroup.Count() } + orderby row.count descending + select row; + foreach (var row in query) + { + var username = await ctx.GetUserNameAsync(row.discordId).ConfigureAwait(false); + result.Append($"{username,-25} | "); + if (ctx.Channel.IsPrivate) + result.Append($"{row.discordId,-18} | "); + result.AppendLine($"{row.count,2}"); + } + await ctx.SendAutosplitMessageAsync(result.Append("```")).ConfigureAwait(false); + } + + [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) + { + await ctx.TriggerTypingAsync().ConfigureAwait(false); + if (number < 1) + number = 10; + var userIdColumn = ctx.Channel.IsPrivate ? $"{"User ID",-18} | " : ""; + var header = $"ID | {"User",-25} | {userIdColumn}{"Issued by",-25} | Reason "; + var result = new StringBuilder("Last issued warnings:").AppendLine("```") + .AppendLine(header) + .AppendLine("".PadLeft(header.Length, '-')); + var query = from warn in BotDb.Instance.Warning + 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); + result.Append($"{row.Id:00000} | {username,-25} | "); + if (ctx.Channel.IsPrivate) + result.Append($"{row.DiscordId,-18} | "); + result.Append($"{modname,-25} | {row.Reason}"); + if (ctx.Channel.IsPrivate) + result.Append(" | ").Append(row.FullReason); + result.AppendLine(); + } + await ctx.SendAutosplitMessageAsync(result.Append("```")).ConfigureAwait(false); + } + + private async Task CheckListPermissionAsync(CommandContext ctx, ulong userId) + { + if (userId == ctx.Message.Author.Id || ModProvider.IsMod(ctx.Message.Author.Id)) + return true; + + await Task.WhenAll( + ctx.Message.CreateReactionAsync(Config.Reactions.Denied), + ctx.RespondAsync("Regular users can only view their own warnings") + ).ConfigureAwait(false); + return false; + } + } + } +} diff --git a/CompatBot/Commands/Warnings.cs b/CompatBot/Commands/Warnings.cs new file mode 100644 index 00000000..241ca7a7 --- /dev/null +++ b/CompatBot/Commands/Warnings.cs @@ -0,0 +1,157 @@ +using System; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CompatApiClient; +using CompatBot.Attributes; +using CompatBot.Database; +using CompatBot.Utils; +using DSharpPlus; +using DSharpPlus.CommandsNext; +using DSharpPlus.CommandsNext.Attributes; +using DSharpPlus.Entities; +using Microsoft.EntityFrameworkCore; + +namespace CompatBot.Commands +{ + [Group("warn")] + [Description("Command used to manage warnings")] + internal sealed partial class Warnings: BaseCommandModule + { + [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; + + var typingTask = ctx.TriggerTypingAsync(); + if (await AddAsync(ctx.Client, ctx.Message, user.Id, user.Username.Sanitize(), ctx.Message.Author, reason).ConfigureAwait(false)) + await ctx.Message.CreateReactionAsync(Config.Reactions.Success).ConfigureAwait(false); + else + await ctx.Message.CreateReactionAsync(Config.Reactions.Failure).ConfigureAwait(false); + await typingTask; + } + + [GroupCommand, RequiresBotModRole] + 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 typingTask = ctx.TriggerTypingAsync(); + if (await AddAsync(ctx.Client, ctx.Message, userId, $"<@{userId}>", ctx.Message.Author, reason).ConfigureAwait(false)) + await ctx.Message.CreateReactionAsync(Config.Reactions.Success).ConfigureAwait(false); + else + await ctx.Message.CreateReactionAsync(Config.Reactions.Failure).ConfigureAwait(false); + await typingTask; + } + + [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 typingTask = ctx.TriggerTypingAsync(); + var warningsToRemove = await BotDb.Instance.Warning.Where(w => ids.Contains(w.Id)).ToListAsync().ConfigureAwait(false); + BotDb.Instance.Warning.RemoveRange(warningsToRemove); + var removedCount = await BotDb.Instance.SaveChangesAsync().ConfigureAwait(false); + (DiscordEmoji reaction, string msg) result = removedCount == ids.Length + ? (Config.Reactions.Success, $"Warning{(ids.Length == 1 ? "" : "s")} successfully removed!") + : (Config.Reactions.Failure, $"Removed {removedCount} items, but was asked to remove {ids.Length}"); + await Task.WhenAll( + ctx.RespondAsync(result.msg), + ctx.Message.CreateReactionAsync(result.reaction), + typingTask + ).ConfigureAwait(false); + } + + [Command("clear"), RequiresBotModRole] + [Description("Removes **all** warings for a user")] + public Task Clear(CommandContext ctx, [Description("User to clear warnings for")] DiscordUser user) + { + return Clear(ctx, user.Id); + } + + [Command("clear"), RequiresBotModRole] + public async Task Clear(CommandContext ctx, [Description("User ID to clear warnings for")] ulong userId) + { + try + { + var typingTask = ctx.TriggerTypingAsync(); + //var removed = await BotDb.Instance.Database.ExecuteSqlCommandAsync($"DELETE FROM `warning` WHERE `discord_id`={userId}").ConfigureAwait(false); + var warningsToRemove = await BotDb.Instance.Warning.Where(w => w.DiscordId == userId).ToListAsync().ConfigureAwait(false); + BotDb.Instance.Warning.RemoveRange(warningsToRemove); + var removed = await BotDb.Instance.SaveChangesAsync().ConfigureAwait(false); + await Task.WhenAll( + ctx.RespondAsync($"{removed} warning{(removed == 1 ? "" : "s")} successfully removed!"), + ctx.Message.CreateReactionAsync(Config.Reactions.Success), + typingTask + ).ConfigureAwait(false); + } + catch (Exception e) + { + ctx.Client.DebugLogger.LogMessage(LogLevel.Error, "", e.ToString(), DateTime.Now); + } + + } + + internal static async Task AddAsync(DiscordClient client, DiscordMessage message, ulong userId, string userName, DiscordUser issuer, string reason, string fullReason = null) + { + if (string.IsNullOrEmpty(reason)) + { + await message.RespondAsync("A reason needs to be provided").ConfigureAwait(false); + return false; + } + try + { + await BotDb.Instance.Warning.AddAsync(new Warning {DiscordId = userId, IssuerId = issuer.Id, Reason = reason, FullReason = fullReason ?? "", Timestamp = DateTime.UtcNow.Ticks}).ConfigureAwait(false); + await BotDb.Instance.SaveChangesAsync().ConfigureAwait(false); + var count = await BotDb.Instance.Warning.CountAsync(w => w.DiscordId == userId).ConfigureAwait(false); + await message.RespondAsync($"User warning saved! User currently has {count} warning{(count % 10 == 1 && count % 100 != 11 ? "" : "s")}!").ConfigureAwait(false); + if (count > 1) + await ListUserWarningsAsync(client, message, userId, userName).ConfigureAwait(false); + return true; + } + catch (Exception e) + { + client.DebugLogger.LogMessage(LogLevel.Error, "", "Couldn't save the warning: " + e, DateTime.Now); + 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) + { + await message.Channel.TriggerTypingAsync().ConfigureAwait(false); + var count = await BotDb.Instance.Warning.CountAsync(w => w.DiscordId == userId).ConfigureAwait(false); + if (count == 0) + { + await message.RespondAsync(userName + " has no warnings, is a standup citizen, and a pillar of this community").ConfigureAwait(false); + return; + } + + if (count == 1 && skipIfOne) + return; + + var isPrivate = message.Channel.IsPrivate; + var result = new StringBuilder("Warning list for ").Append(userName).AppendLine(":") + .AppendLine("```"); + var header = $"{"ID",-5} | {"Issued by",-25} | {"On date",-20} | Reason"; + if (isPrivate) + header += " | Full reason"; + result.AppendLine(header) + .AppendLine("".PadLeft(header.Length, '-')); + foreach (var warning in BotDb.Instance.Warning.Where(w => w.DiscordId == userId)) + { + var issuerName = warning.IssuerId == 0 ? "" : await client.GetUserNameAsync(message.Channel, warning.IssuerId, isPrivate, "unknown mod").ConfigureAwait(false); + var timestamp = warning.Timestamp.HasValue ? new DateTime(warning.Timestamp.Value, DateTimeKind.Utc).ToString("u") : null; + result.Append($"{warning.Id:00000} | {issuerName,-25} | {timestamp,-20} | {warning.Reason}"); + if (isPrivate) + result.Append(" | ").Append(warning.FullReason); + result.AppendLine(); + } + await message.Channel.SendAutosplitMessageAsync(result.Append("```")).ConfigureAwait(false); + } + } +} diff --git a/CompatBot/CompatBot.csproj b/CompatBot/CompatBot.csproj new file mode 100644 index 00000000..34298d9a --- /dev/null +++ b/CompatBot/CompatBot.csproj @@ -0,0 +1,33 @@ + + + + Exe + netcoreapp2.1 + CompatBot + + + + latest + + + + latest + + + + + + + + + + + + + + + + + + + diff --git a/CompatBot/Config.cs b/CompatBot/Config.cs new file mode 100644 index 00000000..f93f339d --- /dev/null +++ b/CompatBot/Config.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using DSharpPlus.Entities; + +namespace CompatBot +{ + internal static class Config + { + public static readonly string CommandPrefix = "!"; + public static readonly ulong BotChannelId = 291679908067803136; + public static readonly ulong BotSpamId = 319224795785068545; + public static readonly ulong BotLogId = 436972161572536329; + public static readonly ulong BotRulesChannelId = 311894275015049216; + public static readonly ulong BotAdminId = 267367850706993152; + + public static readonly int ProductCodeLookupHistoryThrottle = 7; + + public static readonly int TopLimit = 15; + public static readonly int AttachmentSizeLimit = 8 * 1024 * 1024; + public static readonly int LogSizeLimit = 64 * 1024 * 1024; + public static readonly int MinimumBufferSize = 512; + + public static readonly string Token; + + public static readonly CancellationTokenSource Cts = new CancellationTokenSource(); + public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); + + public static class Colors + { + public static readonly DiscordColor Help = DiscordColor.Azure; + public static readonly DiscordColor DownloadLinks = new DiscordColor(0x3b88c3); + public static readonly DiscordColor Maintenance = new DiscordColor(0xffff00); + + public static readonly DiscordColor CompatStatusNothing = new DiscordColor(0x455556); + public static readonly DiscordColor CompatStatusLoadable = new DiscordColor(0xe74c3c); + public static readonly DiscordColor CompatStatusIntro = new DiscordColor(0xe08a1e); + public static readonly DiscordColor CompatStatusIngame = new DiscordColor(0xf9b32f); + public static readonly DiscordColor CompatStatusPlayable = new DiscordColor(0x1ebc61); + public static readonly DiscordColor CompatStatusUnknown = new DiscordColor(0x3198ff); + + public static readonly DiscordColor LogResultFailed = DiscordColor.Gray; + + public static readonly DiscordColor LogAlert = new DiscordColor(0xe74c3c); + public static readonly DiscordColor LogNotice = new DiscordColor(0xf9b32f); + } + + 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 class Moderation + { + public static readonly int StarbucksThreshold = 5; + + public static readonly IReadOnlyList Channels = new List + { + 272875751773306881, + 319224795785068545, + }.AsReadOnly(); + + public static readonly IReadOnlyCollection RoleWhiteList = new HashSet(StringComparer.InvariantCultureIgnoreCase) + { + "Administrator", + "Community Manager", + "Web Developer", + "Moderator", + "Lead Graphics Developer", + "Lead Core Developer", + "Developers", + "Affiliated", + "Contributors", + }; + } + + static Config() + { + try + { + var envVars = Environment.GetEnvironmentVariables(); + foreach (var member in typeof(Config).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + if (envVars.Contains(member.Name)) + { + if (member.FieldType == typeof(ulong) && ulong.TryParse(envVars[member.Name] as string, out var ulongValue)) + member.SetValue(null, ulongValue); + if (member.FieldType == typeof(int) && int.TryParse(envVars[member.Name] as string, out var intValue)) + member.SetValue(null, intValue); + if (member.FieldType == typeof(string)) + member.SetValue(null, envVars[member.Name] as string); + } + } + var args = Environment.CommandLine.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (args.Length > 1) + Token = args[1]; + } + catch (Exception e) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("Error initializing settings: " + e.Message); + Console.ResetColor(); + } + } + } +} \ No newline at end of file diff --git a/CompatBot/Converters/CustomDiscordChannelConverter.cs b/CompatBot/Converters/CustomDiscordChannelConverter.cs new file mode 100644 index 00000000..d22ad32c --- /dev/null +++ b/CompatBot/Converters/CustomDiscordChannelConverter.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using DSharpPlus.CommandsNext; +using DSharpPlus.CommandsNext.Converters; +using DSharpPlus.Entities; + +namespace CompatBot.Converters +{ + internal sealed class CustomDiscordChannelConverter : IArgumentConverter + { + private static Regex ChannelRegex { get; } = new Regex(@"^<#(\d+)>$", RegexOptions.ECMAScript | RegexOptions.Compiled); + + public async Task> ConvertAsync(string value, CommandContext ctx) + { + var guildList = new List(ctx.Client.Guilds.Count); + if (ctx.Guild == null) + foreach (var g in ctx.Client.Guilds.Keys) + guildList.Add(await ctx.Client.GetGuildAsync(g).ConfigureAwait(false)); + else + guildList.Add(ctx.Guild); + + if (ulong.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var cid)) + { + var result = ( + from g in guildList + from ch in g.Channels + select ch + ).FirstOrDefault(xc => xc.Id == cid); + var ret = result == null ? Optional.FromNoValue() : Optional.FromValue(result); + 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.Id == cid); + var ret = result != null ? Optional.FromValue(result) : Optional.FromNoValue(); + return ret; + } + + value = value.ToLowerInvariant(); + var chn = ( + from g in guildList + from ch in g.Channels + select ch + ).FirstOrDefault(xc => xc.Name.ToLowerInvariant() == value); + return chn != null ? Optional.FromValue(chn) : Optional.FromNoValue(); + } + } +} \ No newline at end of file diff --git a/CompatBot/Database/BotDb.cs b/CompatBot/Database/BotDb.cs new file mode 100644 index 00000000..0caf3dbd --- /dev/null +++ b/CompatBot/Database/BotDb.cs @@ -0,0 +1,77 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using CompatApiClient; +using Microsoft.EntityFrameworkCore; + +namespace CompatBot.Database +{ + internal class BotDb: DbContext + { + private static readonly Lazy instance = new Lazy(() => new BotDb()); + public static BotDb Instance => instance.Value; + + public DbSet Moderator { get; set; } + public DbSet Piracystring { get; set; } + public DbSet Warning { get; set; } + public DbSet Explanation { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseSqlite("Data Source=bot.db"); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + //configure indices + modelBuilder.Entity().HasIndex(m => m.DiscordId).IsUnique().HasName("moderator_discord_id"); + + modelBuilder.Entity().HasIndex(ps => ps.String).IsUnique().HasName("piracystring_string"); + + modelBuilder.Entity().HasIndex(w => w.DiscordId).HasName("warning_discord_id"); + + modelBuilder.Entity().HasIndex(e => e.Keyword).IsUnique().HasName("explanation_keyword"); + + //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); + } + } + + internal class Moderator + { + public int Id { get; set; } + public ulong DiscordId { get; set; } + public bool Sudoer { get; set; } + } + + internal class Piracystring + { + public int Id { get; set; } + [Required, Column(TypeName = "varchar(255)")] + public string String { get; set; } + } + + internal class Warning + { + public int Id { get; set; } + public ulong DiscordId { get; set; } + public ulong IssuerId { get; set; } + [Required] + public string Reason { get; set; } + [Required] + public string FullReason { get; set; } + public long? Timestamp { get; set; } + } + + internal class Explanation + { + public int Id { get; set; } + [Required] + public string Keyword { get; set; } + [Required] + public string Text { get; set; } + } +} diff --git a/CompatBot/Database/DbImporter.cs b/CompatBot/Database/DbImporter.cs new file mode 100644 index 00000000..a9bb2f90 --- /dev/null +++ b/CompatBot/Database/DbImporter.cs @@ -0,0 +1,116 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using CompatBot.Database.Migrations; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations.Internal; + +namespace CompatBot.Database +{ + internal static class DbImporter + { + public static async Task UpgradeAsync(BotDb dbContext, CancellationToken cancellationToken) + { + try + { + Console.WriteLine("Upgrading database if needed..."); + await dbContext.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); + } + catch (SqliteException e) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine(e.Message); + Console.WriteLine("Database upgrade failed, probably importing an unversioned one."); + Console.ResetColor(); + Console.WriteLine("Trying to apply a manual fixup..."); + try + { + await ImportAsync(dbContext, cancellationToken).ConfigureAwait(false); + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine("Manual fixup worked great. Let's try migrations again..."); + Console.ResetColor(); + await dbContext.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); + + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine(ex.Message); + Console.WriteLine("Well shit, I hope you had backups, son. You'll have to figure this one out on your own."); + Console.ResetColor(); + return false; + } + } + if (!await dbContext.Moderator.AnyAsync(m => m.DiscordId == Config.BotAdminId, cancellationToken).ConfigureAwait(false)) + { + await dbContext.Moderator.AddAsync(new Moderator {DiscordId = Config.BotAdminId, Sudoer = true}, cancellationToken).ConfigureAwait(false); + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + Console.WriteLine("Database is ready."); + return true; + } + + private static async Task ImportAsync(BotDb dbContext, CancellationToken cancellationToken) + { + var db = dbContext.Database; + using (var tx = await db.BeginTransactionAsync(cancellationToken)) + { + try + { + // __EFMigrationsHistory table will be already created by the failed migration attempt + await db.ExecuteSqlCommandAsync($"INSERT INTO `__EFMigrationsHistory`(`MigrationId`,`ProductVersion`) VALUES ({new InitialCreate().GetId()},'manual')", cancellationToken); + await db.ExecuteSqlCommandAsync($"INSERT INTO `__EFMigrationsHistory`(`MigrationId`,`ProductVersion`) VALUES ({new Explanations().GetId()},'manual')", cancellationToken); + // create constraints on moderator + await db.ExecuteSqlCommandAsync(@"CREATE TABLE `temp_new_moderator` ( + `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + `discord_id` INTEGER NOT NULL, + `sudoer` INTEGER NOT NULL + )", cancellationToken); + await db.ExecuteSqlCommandAsync("INSERT INTO temp_new_moderator SELECT `id`,`discord_id`,`sudoer` FROM `moderator`", cancellationToken); + await db.ExecuteSqlCommandAsync("DROP TABLE `moderator`", cancellationToken); + await db.ExecuteSqlCommandAsync("ALTER TABLE `temp_new_moderator` RENAME TO `moderator`", cancellationToken); + await db.ExecuteSqlCommandAsync("CREATE UNIQUE INDEX `moderator_discord_id` ON `moderator` (`discord_id`)", cancellationToken); + // create constraints on piracystring + await db.ExecuteSqlCommandAsync(@"CREATE TABLE `temp_new_piracystring` ( + `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + `string` varchar ( 255 ) NOT NULL + )", cancellationToken); + await db.ExecuteSqlCommandAsync("INSERT INTO temp_new_piracystring SELECT `id`,`string` FROM `piracystring`", cancellationToken); + await db.ExecuteSqlCommandAsync("DROP TABLE `piracystring`", cancellationToken); + await db.ExecuteSqlCommandAsync("ALTER TABLE `temp_new_piracystring` RENAME TO `piracystring`", cancellationToken); + await db.ExecuteSqlCommandAsync("CREATE UNIQUE INDEX `piracystring_string` ON `piracystring` (`string`)", cancellationToken); + // create constraints on warning + await db.ExecuteSqlCommandAsync(@"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.ExecuteSqlCommandAsync("INSERT INTO temp_new_warning SELECT `id`,`discord_id`,`reason`,`full_reason`,`issuer_id` FROM `warning`", cancellationToken); + await db.ExecuteSqlCommandAsync("DROP TABLE `warning`", cancellationToken); + await db.ExecuteSqlCommandAsync("ALTER TABLE `temp_new_warning` RENAME TO `warning`", cancellationToken); + await db.ExecuteSqlCommandAsync("CREATE INDEX `warning_discord_id` ON `warning` (`discord_id`)", cancellationToken); + // create constraints on explanation + await db.ExecuteSqlCommandAsync(@"CREATE TABLE `temp_new_explanation` ( + `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + `keyword` TEXT NOT NULL, + `text` TEXT NOT NULL + )", cancellationToken); + await db.ExecuteSqlCommandAsync("INSERT INTO temp_new_explanation SELECT `id`,`keyword`,`text` FROM `explanation`", cancellationToken); + await db.ExecuteSqlCommandAsync("DROP TABLE `explanation`", cancellationToken); + await db.ExecuteSqlCommandAsync("ALTER TABLE `temp_new_explanation` RENAME TO `explanation`", cancellationToken); + await db.ExecuteSqlCommandAsync("CREATE UNIQUE INDEX `explanation_keyword` ON `explanation` (`keyword`)", cancellationToken); + tx.Commit(); + } + catch (Exception e) + { + //tx.Rollback(); + tx.Commit(); + throw e; + } + } + } + } +} \ No newline at end of file diff --git a/CompatBot/Database/Migrations/20180709153348_InitialCreate.Designer.cs b/CompatBot/Database/Migrations/20180709153348_InitialCreate.Designer.cs new file mode 100644 index 00000000..49392751 --- /dev/null +++ b/CompatBot/Database/Migrations/20180709153348_InitialCreate.Designer.cs @@ -0,0 +1,88 @@ +// +using CompatBot.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace CompatBot.Database.Migrations +{ + [DbContext(typeof(BotDb))] + [Migration("20180709153348_InitialCreate")] + partial class InitialCreate + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.1.1-rtm-30846"); + + modelBuilder.Entity("CompatBot.Database.Moderator", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("DiscordId") + .HasColumnName("discord_id"); + + b.Property("Sudoer") + .HasColumnName("sudoer"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("DiscordId") + .IsUnique() + .HasName("moderator_discord_id"); + + b.ToTable("moderator"); + }); + + modelBuilder.Entity("CompatBot.Database.Piracystring", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("String") + .IsRequired() + .HasColumnName("string") + .HasColumnType("varchar(255)"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("String") + .IsUnique() + .HasName("piracystring_string"); + + b.ToTable("piracystring"); + }); + + modelBuilder.Entity("CompatBot.Database.Warning", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("DiscordId") + .HasColumnName("discord_id"); + + b.Property("FullReason") + .IsRequired() + .HasColumnName("full_reason"); + + b.Property("Reason") + .IsRequired() + .HasColumnName("reason"); + + b.HasKey("Id") + .HasName("id"); + + b.ToTable("warning"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CompatBot/Database/Migrations/20180709153348_InitialCreate.cs b/CompatBot/Database/Migrations/20180709153348_InitialCreate.cs new file mode 100644 index 00000000..554ae0f6 --- /dev/null +++ b/CompatBot/Database/Migrations/20180709153348_InitialCreate.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace CompatBot.Database.Migrations +{ + public partial class InitialCreate : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "moderator", + columns: table => new + { + id = table.Column(nullable: false) + .Annotation("Sqlite:Autoincrement", true) + , + discord_id = table.Column(nullable: false), + sudoer = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("id", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "piracystring", + columns: table => new + { + id = table.Column(nullable: false) + .Annotation("Sqlite:Autoincrement", true) + , + @string = table.Column(name: "string", type: "varchar(255)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("id", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "warning", + columns: table => new + { + id = table.Column(nullable: false) + .Annotation("Sqlite:Autoincrement", true) + , + discord_id = table.Column(nullable: false), + reason = table.Column(nullable: false), + full_reason = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("id", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "moderator_discord_id", + table: "moderator", + column: "discord_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "piracystring_string", + table: "piracystring", + column: "string", + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "moderator"); + + migrationBuilder.DropTable( + name: "piracystring"); + + migrationBuilder.DropTable( + name: "warning"); + } + } +} diff --git a/CompatBot/Database/Migrations/20180709154128_Explanations.Designer.cs b/CompatBot/Database/Migrations/20180709154128_Explanations.Designer.cs new file mode 100644 index 00000000..83150be0 --- /dev/null +++ b/CompatBot/Database/Migrations/20180709154128_Explanations.Designer.cs @@ -0,0 +1,118 @@ +// +using CompatBot.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace CompatBot.Database.Migrations +{ + [DbContext(typeof(BotDb))] + [Migration("20180709154128_Explanations")] + partial class Explanations + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.1.1-rtm-30846"); + + modelBuilder.Entity("CompatBot.Database.Explanation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("Keyword") + .IsRequired() + .HasColumnName("keyword"); + + b.Property("Text") + .IsRequired() + .HasColumnName("text"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("Keyword") + .IsUnique() + .HasName("explanation_keyword"); + + b.ToTable("explanation"); + }); + + modelBuilder.Entity("CompatBot.Database.Moderator", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("DiscordId") + .HasColumnName("discord_id"); + + b.Property("Sudoer") + .HasColumnName("sudoer"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("DiscordId") + .IsUnique() + .HasName("moderator_discord_id"); + + b.ToTable("moderator"); + }); + + modelBuilder.Entity("CompatBot.Database.Piracystring", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("String") + .IsRequired() + .HasColumnName("string") + .HasColumnType("varchar(255)"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("String") + .IsUnique() + .HasName("piracystring_string"); + + b.ToTable("piracystring"); + }); + + modelBuilder.Entity("CompatBot.Database.Warning", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("DiscordId") + .HasColumnName("discord_id"); + + b.Property("FullReason") + .IsRequired() + .HasColumnName("full_reason"); + + b.Property("IssuerId") + .HasColumnName("issuer_id"); + + b.Property("Reason") + .IsRequired() + .HasColumnName("reason"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("DiscordId") + .HasName("warning_discord_id"); + + b.ToTable("warning"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CompatBot/Database/Migrations/20180709154128_Explanations.cs b/CompatBot/Database/Migrations/20180709154128_Explanations.cs new file mode 100644 index 00000000..5fd055d3 --- /dev/null +++ b/CompatBot/Database/Migrations/20180709154128_Explanations.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace CompatBot.Database.Migrations +{ + public partial class Explanations : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "issuer_id", + table: "warning", + nullable: false, + defaultValue: 0ul); + + migrationBuilder.CreateTable( + name: "explanation", + columns: table => new + { + id = table.Column(nullable: false) + .Annotation("Sqlite:Autoincrement", true) + , + keyword = table.Column(nullable: false), + text = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("id", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "warning_discord_id", + table: "warning", + column: "discord_id"); + + migrationBuilder.CreateIndex( + name: "explanation_keyword", + table: "explanation", + column: "keyword", + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "explanation"); + + migrationBuilder.DropIndex( + name: "warning_discord_id", + table: "warning"); + + migrationBuilder.DropColumn( + name: "issuer_id", + table: "warning"); + } + } +} diff --git a/CompatBot/Database/Migrations/20180719122730_WarningTimestamp.Designer.cs b/CompatBot/Database/Migrations/20180719122730_WarningTimestamp.Designer.cs new file mode 100644 index 00000000..64530326 --- /dev/null +++ b/CompatBot/Database/Migrations/20180719122730_WarningTimestamp.Designer.cs @@ -0,0 +1,122 @@ +// +using System; +using CompatBot.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace CompatBot.Database.Migrations +{ + [DbContext(typeof(BotDb))] + [Migration("20180719122730_WarningTimestamp")] + partial class WarningTimestamp + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.1.1-rtm-30846"); + + modelBuilder.Entity("CompatBot.Database.Explanation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("Keyword") + .IsRequired() + .HasColumnName("keyword"); + + b.Property("Text") + .IsRequired() + .HasColumnName("text"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("Keyword") + .IsUnique() + .HasName("explanation_keyword"); + + b.ToTable("explanation"); + }); + + modelBuilder.Entity("CompatBot.Database.Moderator", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("DiscordId") + .HasColumnName("discord_id"); + + b.Property("Sudoer") + .HasColumnName("sudoer"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("DiscordId") + .IsUnique() + .HasName("moderator_discord_id"); + + b.ToTable("moderator"); + }); + + modelBuilder.Entity("CompatBot.Database.Piracystring", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("String") + .IsRequired() + .HasColumnName("string") + .HasColumnType("varchar(255)"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("String") + .IsUnique() + .HasName("piracystring_string"); + + b.ToTable("piracystring"); + }); + + modelBuilder.Entity("CompatBot.Database.Warning", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("DiscordId") + .HasColumnName("discord_id"); + + b.Property("FullReason") + .IsRequired() + .HasColumnName("full_reason"); + + b.Property("IssuerId") + .HasColumnName("issuer_id"); + + b.Property("Reason") + .IsRequired() + .HasColumnName("reason"); + + b.Property("Timestamp") + .HasColumnName("timestamp"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("DiscordId") + .HasName("warning_discord_id"); + + b.ToTable("warning"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CompatBot/Database/Migrations/20180719122730_WarningTimestamp.cs b/CompatBot/Database/Migrations/20180719122730_WarningTimestamp.cs new file mode 100644 index 00000000..8d73832f --- /dev/null +++ b/CompatBot/Database/Migrations/20180719122730_WarningTimestamp.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace CompatBot.Database.Migrations +{ + public partial class WarningTimestamp : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "timestamp", + table: "warning", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "timestamp", + table: "warning"); + } + } +} diff --git a/CompatBot/Database/Migrations/BotDbModelSnapshot.cs b/CompatBot/Database/Migrations/BotDbModelSnapshot.cs new file mode 100644 index 00000000..8eef7e06 --- /dev/null +++ b/CompatBot/Database/Migrations/BotDbModelSnapshot.cs @@ -0,0 +1,120 @@ +// +using System; +using CompatBot.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace CompatBot.Database.Migrations +{ + [DbContext(typeof(BotDb))] + partial class BotDbModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.1.1-rtm-30846"); + + modelBuilder.Entity("CompatBot.Database.Explanation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("Keyword") + .IsRequired() + .HasColumnName("keyword"); + + b.Property("Text") + .IsRequired() + .HasColumnName("text"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("Keyword") + .IsUnique() + .HasName("explanation_keyword"); + + b.ToTable("explanation"); + }); + + modelBuilder.Entity("CompatBot.Database.Moderator", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("DiscordId") + .HasColumnName("discord_id"); + + b.Property("Sudoer") + .HasColumnName("sudoer"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("DiscordId") + .IsUnique() + .HasName("moderator_discord_id"); + + b.ToTable("moderator"); + }); + + modelBuilder.Entity("CompatBot.Database.Piracystring", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("String") + .IsRequired() + .HasColumnName("string") + .HasColumnType("varchar(255)"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("String") + .IsUnique() + .HasName("piracystring_string"); + + b.ToTable("piracystring"); + }); + + modelBuilder.Entity("CompatBot.Database.Warning", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("DiscordId") + .HasColumnName("discord_id"); + + b.Property("FullReason") + .IsRequired() + .HasColumnName("full_reason"); + + b.Property("IssuerId") + .HasColumnName("issuer_id"); + + b.Property("Reason") + .IsRequired() + .HasColumnName("reason"); + + b.Property("Timestamp") + .HasColumnName("timestamp"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("DiscordId") + .HasName("warning_discord_id"); + + b.ToTable("warning"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CompatBot/Database/NamingConventionConverter.cs b/CompatBot/Database/NamingConventionConverter.cs new file mode 100644 index 00000000..e17d787b --- /dev/null +++ b/CompatBot/Database/NamingConventionConverter.cs @@ -0,0 +1,27 @@ +using System; +using Microsoft.EntityFrameworkCore; + +namespace CompatBot.Database +{ + internal static class NamingConventionConverter + { + public static void ConfigureMapping(this ModelBuilder modelBuilder, Func nameResolver) + { + if (nameResolver == null) + throw new ArgumentNullException(nameof(nameResolver)); + + foreach (var entity in modelBuilder.Model.GetEntityTypes()) + { + entity.Relational().TableName = nameResolver(entity.Relational().TableName); + foreach (var property in entity.GetProperties()) + property.Relational().ColumnName = nameResolver(property.Name); + foreach (var key in entity.GetKeys()) + key.Relational().Name = nameResolver(key.Relational().Name); + foreach (var key in entity.GetForeignKeys()) + key.Relational().Name = nameResolver(key.Relational().Name); + foreach (var index in entity.GetIndexes()) + index.Relational().Name = nameResolver(index.Relational().Name); + } + } + } +} diff --git a/CompatBot/Database/PrimaryKeyConvention.cs b/CompatBot/Database/PrimaryKeyConvention.cs new file mode 100644 index 00000000..0d5cd48e --- /dev/null +++ b/CompatBot/Database/PrimaryKeyConvention.cs @@ -0,0 +1,32 @@ +using System; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Internal; + +namespace CompatBot.Database +{ + internal static class PrimaryKeyConvention + { + public static void ConfigureDefaultPkConvention(this ModelBuilder modelBuilder, string keyProperty = "Id") + { + if (string.IsNullOrEmpty(keyProperty)) + throw new ArgumentException(nameof(keyProperty)); + + foreach (var entity in modelBuilder.Model.GetEntityTypes()) + { + var pk = entity.GetKeys().FirstOrDefault(k => k.IsPrimaryKey()); + if (pk != null) + pk.Relational().Name = keyProperty; + } + } + + public static void ConfigureNoPkConvention(this ModelBuilder modelBuilder) + { + foreach (var entity in modelBuilder.Model.GetEntityTypes()) + { + var pk = entity.GetKeys().FirstOrDefault(k => k.IsPrimaryKey()); + entity.RemoveKey(pk.Properties); + } + } + } +} \ No newline at end of file diff --git a/CompatBot/EventHandlers/AntipiracyMonitor.cs b/CompatBot/EventHandlers/AntipiracyMonitor.cs new file mode 100644 index 00000000..3da1ab46 --- /dev/null +++ b/CompatBot/EventHandlers/AntipiracyMonitor.cs @@ -0,0 +1,63 @@ +using System; +using System.Threading.Tasks; +using CompatBot.Commands; +using CompatBot.Providers; +using CompatBot.Utils; +using DSharpPlus; +using DSharpPlus.Entities; +using DSharpPlus.EventArgs; + +namespace CompatBot.EventHandlers +{ + internal static class AntipiracyMonitor + { + public static async Task OnMessageCreated(MessageCreateEventArgs args) + { + args.Handled = !await IsClean(args.Client, args.Message).ConfigureAwait(false); + } + + public static async Task OnMessageEdit(MessageUpdateEventArgs args) + { + args.Handled = !await IsClean(args.Client, args.Message).ConfigureAwait(false); + } + + private static async Task IsClean(DiscordClient client, DiscordMessage message) + { + if (message.Author.IsBot) + return true; + + if (string.IsNullOrEmpty(message.Content) || message.Content.StartsWith(Config.CommandPrefix)) + return true; + + string trigger = null; + bool needsAttention = false; + try + { + trigger = await PiracyStringProvider.FindTriggerAsync(message.Content); + if (trigger == null) + return true; + + await message.Channel.DeleteMessageAsync(message, $"Mention of piracy trigger '{trigger}'").ConfigureAwait(false); + } + catch (Exception e) + { + client.DebugLogger.LogMessage(LogLevel.Warning, "", $"Couldn't delete message in {message.Channel.Name}: {e.Message}", DateTime.Now); + needsAttention = true; + } + try + { + var rules = await client.GetChannelAsync(Config.BotRulesChannelId).ConfigureAwait(false); + await Task.WhenAll( + message.Channel.SendMessageAsync($"{message.Author.Mention} Please follow the {rules.Mention} and do not discuss piracy on this server. Repeated offence may result in a ban."), + client.ReportAsync("Mention of piracy", message, trigger, message.Content, needsAttention), + Warnings.AddAsync(client, message, message.Author.Id, message.Author.Username, client.CurrentUser, "Mention of piracy", message.Content) + ).ConfigureAwait(false); + } + catch (Exception e) + { + client.DebugLogger.LogMessage(LogLevel.Warning, "", $"Couldn't finish piracy trigger actions for a message in {message.Channel.Name}: {e}", DateTime.Now); + } + return false; + } + } +} diff --git a/CompatBot/EventHandlers/LogInfoHandler.cs b/CompatBot/EventHandlers/LogInfoHandler.cs new file mode 100644 index 00000000..64da7177 --- /dev/null +++ b/CompatBot/EventHandlers/LogInfoHandler.cs @@ -0,0 +1,91 @@ +using System; +using System.IO.Pipelines; +using System.Linq; +using System.Threading.Tasks; +using CompatBot.Commands; +using CompatBot.LogParsing; +using CompatBot.LogParsing.SourceHandlers; +using CompatBot.ResultFormatters; +using CompatBot.Utils; +using DSharpPlus; +using DSharpPlus.EventArgs; + +namespace CompatBot.EventHandlers +{ + internal static class LogInfoHandler + { + private static readonly ISourceHandler[] handlers = + { + new GzipHandler(), + new PlainTextHandler(), + new ZipHandler(), + }; + + private static readonly char[] linkSeparator = {' ', '>', '\r', '\n'}; + + public static async Task OnMessageCreated(MessageCreateEventArgs args) + { + var message = args.Message; + if (message.Author.IsBot) + return; + + if (!string.IsNullOrEmpty(message.Content) && message.Content.StartsWith(Config.CommandPrefix)) + return; + + foreach (var attachment in message.Attachments.Where(a => a.FileSize < Config.AttachmentSizeLimit)) + foreach (var handler in handlers) + if (await handler.CanHandleAsync(attachment).ConfigureAwait(false)) + { + await args.Channel.TriggerTypingAsync().ConfigureAwait(false); + LogParseState result = null; + try + { + var pipe = new Pipe(); + var fillPipeTask = handler.FillPipeAsync(attachment, pipe.Writer); + result = await LogParser.ReadPipeAsync(pipe.Reader).ConfigureAwait(false); + await fillPipeTask.ConfigureAwait(false); + } + catch (Exception e) + { + args.Client.DebugLogger.LogMessage(LogLevel.Error, "", "Log parsing failed: " + e, DateTime.Now); + } + if (result == null) + await args.Channel.SendMessageAsync("Log analysis failed, most likely cause is a truncated/invalid log.").ConfigureAwait(false); + else + { + await args.Channel.SendMessageAsync(embed: await result.AsEmbedAsync(args.Client, args.Message).ConfigureAwait(false)).ConfigureAwait(false); + if (result.Error == LogParseState.ErrorCode.PiracyDetected) + { + bool needsAttention = false; + try + { + await message.DeleteAsync("Piracy detected in log").ConfigureAwait(false); + } + catch (Exception e) + { + needsAttention = true; + args.Client.DebugLogger.LogMessage(LogLevel.Warning, "", $"Unable to delete message in {args.Channel.Name}: {e.Message}", DateTime.Now); + } + await Task.WhenAll( + args.Client.ReportAsync("Pirated Release", args.Message, result.PiracyTrigger, result.PiracyContext, needsAttention), + Warnings.AddAsync(args.Client, args.Message, args.Message.Author.Id, args.Message.Author.Username, args.Client.CurrentUser, + "Pirated Release", $"{message.CreationTimestamp:O} - {message.Content} - {result.PiracyTrigger}") + ); + } + } + return; + } + + if (string.IsNullOrEmpty(message.Content) || !"help".Equals(args.Channel.Name, StringComparison.InvariantCultureIgnoreCase)) + return; + + var linkStart = message.Content.IndexOf("http"); + if (linkStart > -1) + { + var link = message.Content.Substring(linkStart).Split(linkSeparator, 2)[0]; + if (link.Contains(".log", StringComparison.InvariantCultureIgnoreCase) || link.Contains("rpcs3.zip", StringComparison.CurrentCultureIgnoreCase)) + await args.Channel.SendMessageAsync("If you intended to upload a log file please reupload it directly to discord").ConfigureAwait(false); + } + } + } +} diff --git a/CompatBot/EventHandlers/LogsAsTextMonitor.cs b/CompatBot/EventHandlers/LogsAsTextMonitor.cs new file mode 100644 index 00000000..892a27d3 --- /dev/null +++ b/CompatBot/EventHandlers/LogsAsTextMonitor.cs @@ -0,0 +1,26 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using DSharpPlus.EventArgs; + +namespace CompatBot.EventHandlers +{ + internal class LogsAsTextMonitor + { + public static async Task OnMessageCreated(MessageCreateEventArgs args) + { + if (args.Author.IsBot) + return; + + if (string.IsNullOrEmpty(args.Message.Content) || args.Message.Content.StartsWith(Config.CommandPrefix)) + return; + + if (!"help".Equals(args.Channel.Name, StringComparison.InvariantCultureIgnoreCase)) + return; + + if (args.Message.Content.Contains('·')) + if (args.Message.Content.Split('\n', StringSplitOptions.RemoveEmptyEntries).Any(l => l.StartsWith('·'))) + await args.Channel.SendMessageAsync($"{args.Message.Author.Mention} please upload the full log file instead of pasting some random lines that might be completely irrelevant").ConfigureAwait(false); + } + } +} diff --git a/CompatBot/EventHandlers/ProductCodeLookup.cs b/CompatBot/EventHandlers/ProductCodeLookup.cs new file mode 100644 index 00000000..b1d3a94b --- /dev/null +++ b/CompatBot/EventHandlers/ProductCodeLookup.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using CompatApiClient; +using CompatApiClient.POCOs; +using CompatBot.ResultFormatters; +using CompatBot.Utils; +using DSharpPlus; +using DSharpPlus.Entities; +using DSharpPlus.EventArgs; + +namespace CompatBot.EventHandlers +{ + internal static class ProductCodeLookup + { + // see http://www.psdevwiki.com/ps3/Productcode + private static readonly Regex ProductCode = new Regex(@"(?(?:[BPSUVX][CL]|P[ETU]|NP)[AEHJKPUIX][ABSM])[ \-]?(?\d{5})", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Client compatClient = new Client(); + + public static async Task OnMessageMention(MessageCreateEventArgs args) + { + if (args.Author.IsBot) + return; + + if (string.IsNullOrEmpty(args.Message.Content) || args.Message.Content.StartsWith(Config.CommandPrefix)) + return; + + var lastBotMessages = await args.Channel.GetMessagesBeforeAsync(args.Message.Id, Config.ProductCodeLookupHistoryThrottle, DateTime.UtcNow.AddSeconds(-30)).ConfigureAwait(false); + StringBuilder previousRepliesBuilder = null; + foreach (var msg in lastBotMessages) + { + if (NeedToSilence(msg)) + return; + + if (msg.Author.IsCurrent) + { + previousRepliesBuilder = previousRepliesBuilder ?? new StringBuilder(); + previousRepliesBuilder.AppendLine(msg.Content); + var embeds = msg.Embeds; + if (embeds?.Count > 0) + foreach (var embed in embeds) + previousRepliesBuilder.AppendLine(embed.Title).AppendLine(embed.Description); + } + } + var previousReplies = previousRepliesBuilder?.ToString() ?? ""; + + var codesToLookup = ProductCode.Matches(args.Message.Content) + .Select(match => (match.Groups["letters"].Value + match.Groups["numbers"]).ToUpper()) + .Distinct() + .Where(c => !previousReplies.Contains(c, StringComparison.InvariantCultureIgnoreCase)) + .Take(args.Channel.IsPrivate ? 50 : 5) + .ToList(); + if (codesToLookup.Count == 0) + return; + + await args.Channel.TriggerTypingAsync().ConfigureAwait(false); + var results = new List<(string code, Task task)>(codesToLookup.Count); + foreach (var code in codesToLookup) + results.Add((code, args.Client.LookupGameInfoAsync(code))); + foreach (var result in results) + try + { + await args.Channel.SendMessageAsync(embed: await result.task.ConfigureAwait(false)).ConfigureAwait(false); + } + catch (Exception e) + { + args.Client.DebugLogger.LogMessage(LogLevel.Warning, "", $"Couldn't post result for {result.code}: {e.Message}", DateTime.Now); + } + } + + private static bool NeedToSilence(DiscordMessage msg) + { + if (string.IsNullOrEmpty(msg.Content)) + return false; + + if (!msg.Content.Contains("shut up") && !msg.Content.Contains("hush")) + return false; + + return msg.Content.Contains("bot") || (msg.MentionedUsers?.Any(u => u.IsCurrent) ?? false); + + } + + public static async Task LookupGameInfoAsync(this DiscordClient client, string code, bool footer = true) + { + if (string.IsNullOrEmpty(code)) + return TitleInfo.Unknown.AsEmbed(code, footer); + + try + { + var result = await compatClient.GetCompatResultAsync(RequestBuilder.Start().SetSearch(code), Config.Cts.Token).ConfigureAwait(false); + if (result?.ReturnCode == -2) + return TitleInfo.Maintenance.AsEmbed(null, footer); + + if (result?.ReturnCode == -1) + return TitleInfo.CommunicationError.AsEmbed(null, footer); + + if (result?.Results == null) + return TitleInfo.Unknown.AsEmbed(code, footer); + + if (result.Results.TryGetValue(code, out var info)) + return info.AsEmbed(code, footer); + + return TitleInfo.Unknown.AsEmbed(code, footer); + } + catch (Exception e) + { + client.DebugLogger.LogMessage(LogLevel.Warning, "", $"Couldn't get compat result for {code}: {e}", DateTime.Now); + return TitleInfo.CommunicationError.AsEmbed(null, footer); + } + } + } +} diff --git a/CompatBot/EventHandlers/Starbucks.cs b/CompatBot/EventHandlers/Starbucks.cs new file mode 100644 index 00000000..70db373b --- /dev/null +++ b/CompatBot/EventHandlers/Starbucks.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CompatBot.Utils; +using DSharpPlus; +using DSharpPlus.Entities; +using DSharpPlus.EventArgs; + +namespace CompatBot.EventHandlers +{ + internal static class Starbucks + { + private static readonly TimeSpan ModerationTimeThreshold = TimeSpan.FromHours(12); + + public static Task Handler(MessageReactionAddEventArgs args) + { + return CheckMessage(args.Client, args.Channel, args.User, args.Message, args.Emoji); + } + + public static async Task CheckBacklog(this DiscordClient client) + { + try + { + var after = DateTime.UtcNow - ModerationTimeThreshold; + var checkTasks = new List(); + foreach (var channelId in Config.Moderation.Channels) + { + DiscordChannel channel; + try + { + channel = await client.GetChannelAsync(channelId).ConfigureAwait(false); + } + catch (Exception e) + { + client.DebugLogger.LogMessage(LogLevel.Warning, "", $"Couldn't check channel {channelId} for starbucks: {e.Message}", DateTime.Now); + continue; + } + + var messages = await channel.GetMessagesAsync().ConfigureAwait(false); + + var messagesToCheck = from msg in messages + where msg.CreationTimestamp > after && msg.Reactions.Any(r => r.Emoji == Config.Reactions.Starbucks && r.Count >= Config.Moderation.StarbucksThreshold) + select msg; + foreach (var message in messagesToCheck) + { + var reactionUsers = await message.GetReactionsAsync(Config.Reactions.Starbucks).ConfigureAwait(false); + if (reactionUsers.Count > 0) + checkTasks.Add(CheckMessage(client, channel, reactionUsers[0], message, Config.Reactions.Starbucks)); + } + } + await Task.WhenAll(checkTasks).ConfigureAwait(false); + } + catch (Exception e) + { + client.DebugLogger.LogMessage(LogLevel.Error, "", e.ToString(), DateTime.Now); + } + + } + + private static async Task CheckMessage(DiscordClient client, DiscordChannel channel, DiscordUser user, DiscordMessage message, DiscordEmoji emoji) + { + try + { + if (user.IsBot || channel.IsPrivate) + return; + + if (emoji != Config.Reactions.Starbucks) + return; + + if (!Config.Moderation.Channels.Contains(channel.Id)) + return; + + // message.Timestamp throws if it's not in the cache AND is in local time zone + if (DateTime.UtcNow - message.CreationTimestamp > ModerationTimeThreshold) + return; + + if (message.Reactions.Any(r => r.Emoji == emoji && (r.IsMe || r.Count < Config.Moderation.StarbucksThreshold))) + return; + + // in case it's not in cache and doesn't contain any info, including Author + message = await channel.GetMessageAsync(message.Id).ConfigureAwait(false); + if ((message.Author as DiscordMember)?.Roles.Any(r => Config.Moderation.RoleWhiteList.Contains(r.Name)) ?? false) + return; + + var users = await message.GetReactionsAsync(emoji).ConfigureAwait(false); + var members = users + .Select(u => channel.Guild + .GetMemberAsync(u.Id) + .ContinueWith(ct => ct.IsCompletedSuccessfully ? ct : Task.FromResult((DiscordMember)null))) + .ToList() //force eager task creation + .Select(t => t.Unwrap().ConfigureAwait(false).GetAwaiter().GetResult()) + .Where(m => m != null) + .ToList(); + var reporters = new List(Config.Moderation.StarbucksThreshold); + foreach (var member in members) + { + if (member.IsCurrent) + return; + + if (member.Roles.Any()) + reporters.Add(member); + } + if (reporters.Count < Config.Moderation.StarbucksThreshold) + return; + + await message.CreateReactionAsync(emoji).ConfigureAwait(false); + await client.ReportAsync("User moderation report ⭐💵", message, reporters).ConfigureAwait(false); + + } + catch (Exception e) + { + client.DebugLogger.LogMessage(LogLevel.Error, "", e.ToString(), DateTime.Now); + } + } + } +} diff --git a/CompatBot/LogParsing/LogParser.LogSections.cs b/CompatBot/LogParsing/LogParser.LogSections.cs new file mode 100644 index 00000000..aae0a6b3 --- /dev/null +++ b/CompatBot/LogParsing/LogParser.LogSections.cs @@ -0,0 +1,153 @@ +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using CompatBot.Providers; + +namespace CompatBot.LogParsing +{ + internal partial class LogParser + { + private const RegexOptions DefaultOptions = RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.ExplicitCapture; + + /* + * Extractors are defined in terms of trigger-extractor + * + * Parser scans the log from section to section with a sliding window of up to 50 lines of text + * Triggers are scanned for in the first line of said sliding window + * If trigger is matched, then the associated reges will be run on THE WHOLE sliding window + * If any data was captured, it will be stored in the current collection of items with the key of the explicit capture group of regex + * + * Due to limitations, REGEX can't contain anything other than ASCII (triggers CAN however) + * + */ + private static readonly List LogSections = new List + { + new LogSection + { + Extractors = new Dictionary + { + ["RPCS3"] = new Regex(@"(?.*)\r?$", RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.ExplicitCapture), + }, + EndTrigger = "·", + }, + new LogSection + { + Extractors = new Dictionary + { + ["Serial:"] = new Regex(@"Serial: (?[A-z]{4}\d{5})\r?$", DefaultOptions), + ["Path:"] = new Regex(@"Path: ((?\w:/)|(?/[^/])).*?\r?$", DefaultOptions), + ["custom config:"] = new Regex("custom config: (?.*?)\r?$", DefaultOptions), + }, + OnNewLineAsync = PiracyCheckAsync, + EndTrigger = "Core:", + }, + new LogSection + { + Extractors = new Dictionary + { + ["PPU Decoder:"] = new Regex("PPU Decoder: (?.*?)\r?$", DefaultOptions), + ["PPU Threads:"] = new Regex("Threads: (?.*?)\r?$", DefaultOptions), + ["thread scheduler:"] = new Regex("scheduler: (?.*?)\r?$", DefaultOptions), + ["SPU Decoder:"] = new Regex("SPU Decoder: (?.*?)\r?$", DefaultOptions), + ["secondary cores:"] = new Regex("secondary cores: (?.*?)\r?$", DefaultOptions), + ["priority:"] = new Regex("priority: (?.*?)\r?$", DefaultOptions), + ["SPU Threads:"] = new Regex("SPU Threads: (?.*?)\r?$", DefaultOptions), + ["delay penalty:"] = new Regex("penalty: (?.*?)\r?$", DefaultOptions), + ["loop detection:"] = new Regex("detection: (?.*?)\r?$", DefaultOptions), + ["Lib Loader:"] = new Regex("[Ll]oader: (?.*?)\r?$", DefaultOptions), + ["static functions:"] = new Regex("functions: (?.*?)\r?$", DefaultOptions), + ["Load libraries:"] = new Regex("libraries: (?.*)", DefaultOptions), + }, + EndTrigger = "VFS:", + }, + new LogSection + { + EndTrigger = "Video:", + }, + new LogSection + { + Extractors = new Dictionary + { + ["Renderer:"] = new Regex("Renderer: (?.*?)\r?$", DefaultOptions), + ["Resolution:"] = new Regex("Resolution: (?.*?)\r?$", DefaultOptions), + ["Aspect ratio:"] = new Regex("Aspect ratio: (?.*?)\r?$", DefaultOptions), + ["Frame limit:"] = new Regex("Frame limit: (?.*?)\r?$", DefaultOptions), + ["Write Color Buffers:"] = new Regex("Write Color Buffers: (?.*?)\r?$", DefaultOptions), + ["VSync:"] = new Regex("VSync: (?.*?)\r?$", DefaultOptions), + ["GPU texture scaling:"] = new Regex("Use GPU texture scaling: (?.*?)\r?$", DefaultOptions), + ["Strict Rendering Mode:"] = new Regex("Strict Rendering Mode: (?.*?)\r?$", DefaultOptions), + ["Vertex Cache:"] = new Regex("Disable Vertex Cache: (?.*?)\r?$", DefaultOptions), + ["Blit:"] = new Regex("Blit: (?.*?)\r?$", DefaultOptions), + ["Resolution Scale:"] = new Regex("Resolution Scale: (?.*?)\r?$", DefaultOptions), + ["Anisotropic Filter"] = new Regex("Anisotropic Filter Override: (?.*?)\r?$", DefaultOptions), + ["Scalable Dimension:"] = new Regex("Minimum Scalable Dimension: (?.*?)\r?$", DefaultOptions), + ["12:"] = new Regex(@"(D3D12|DirectX 12):\s*\r?\n\s*Adapter: (?.*?)\r?$", DefaultOptions), + ["Vulkan:"] = new Regex(@"Vulkan:\s*\r?\n\s*Adapter: (?.*?)\r?$", DefaultOptions), + }, + EndTrigger = "Audio:", + }, + new LogSection + { + EndTrigger = "Log:", + OnSectionEnd = MarkAsComplete, + }, + new LogSection + { + Extractors = new Dictionary + { + ["RSX:"] = new Regex(@"RSX:(\d|\.|\s|\w|-)* (?(\d+\.)*\d+)\r?\n[^\n]*?" + + @"RSX: [^\n]+\r?\n[^\n]*?" + + @"RSX: (?.*?)\r?\n[^\n]*?" + + @"RSX: Supported texel buffer size", DefaultOptions), + ["GL RENDERER:"] = new Regex(@"GL RENDERER: (?.*?)\r?\n", DefaultOptions), + ["GL VERSION:"] = new Regex(@"GL VERSION:(\d|\.|\s|\w|-)* (?(\d+\.)*\d+)\r?\n", DefaultOptions), + ["texel buffer size reported:"] = new Regex(@"RSX: Supported texel buffer size reported: (?\d*?) bytes", DefaultOptions), + ["·F "] = new Regex(@"F \d+:\d+:\d+\.\d+ {.+?} (?.*?)\r?$", DefaultOptions), + }, + OnSectionEnd = MarkAsCompleteAndReset, + EndTrigger = "Objects cleared...", + } + }; + + private static async Task PiracyCheckAsync(string line, LogParseState state) + { + if (await PiracyStringProvider.FindTriggerAsync(line).ConfigureAwait(false) is string match) + { + state.PiracyTrigger = match; + state.PiracyContext = Utf8.GetString(Encoding.ASCII.GetBytes(line)); + state.Error = LogParseState.ErrorCode.PiracyDetected; + } + } + + private static void ClearResults(LogParseState state) + { + void Copy(params string[] keys) + { + foreach (var key in keys) + if (state.CompleteCollection?[key] is string value) + state.WipCollection[key] = value; + } + state.WipCollection = new NameValueCollection(); + Copy( + "build_and_specs", + "vulkan_gpu", "d3d_gpu", + "driver_version", "driver_manuf", + "driver_manuf_new", "driver_version_new" + ); + } + + private static void MarkAsComplete(LogParseState state) + { + state.CompleteCollection = state.WipCollection; + } + + private static void MarkAsCompleteAndReset(LogParseState state) + { + MarkAsComplete(state); + ClearResults(state); + state.Id = -1; + } + } +} \ No newline at end of file diff --git a/CompatBot/LogParsing/LogParser.PipeReader.cs b/CompatBot/LogParsing/LogParser.PipeReader.cs new file mode 100644 index 00000000..a5166107 --- /dev/null +++ b/CompatBot/LogParsing/LogParser.PipeReader.cs @@ -0,0 +1,109 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO.Pipelines; +using System.Linq; +using System.Threading.Tasks; +using CompatBot.Utils; + +namespace CompatBot.LogParsing +{ + internal static partial class LogParser + { + private static readonly byte[] Bom = {0xEF, 0xBB, 0xBF}; + + public static async Task ReadPipeAsync(PipeReader reader) + { + var currentSectionLines = new LinkedList>(); + var state = new LogParseState(); + bool skippedBom = false; + long totalReadBytes = 0; + ReadResult result; + do + { + result = await reader.ReadAsync(Config.Cts.Token).ConfigureAwait(false); + var buffer = result.Buffer; + if (!skippedBom) + { + if (buffer.Length < 3) + continue; + + var potentialBom = buffer.Slice(0, 3); + if (potentialBom.ToArray().SequenceEqual(Bom)) + { + reader.AdvanceTo(potentialBom.End); + totalReadBytes += potentialBom.Length; + skippedBom = true; + continue; + } + skippedBom = true; + } + SequencePosition? lineEnd; + do + { + if (currentSectionLines.Count > 0) + buffer = buffer.Slice(buffer.GetPosition(1, currentSectionLines.Last.Value.End)); + lineEnd = buffer.PositionOf((byte)'\n'); + if (lineEnd != null) + { + await OnNewLineAsync(buffer.Slice(0, lineEnd.Value), result.Buffer, currentSectionLines, state).ConfigureAwait(false); + if (state.Error != LogParseState.ErrorCode.None) + return state; + + buffer = buffer.Slice(buffer.GetPosition(1, lineEnd.Value)); + } + } while (lineEnd != null); + + if (result.IsCanceled || Config.Cts.IsCancellationRequested) + state.Error = LogParseState.ErrorCode.SizeLimit; + else if (result.IsCompleted) + await FlushAllLinesAsync(result.Buffer, currentSectionLines, state).ConfigureAwait(false); + var sectionStart = currentSectionLines.Count == 0 ? buffer : currentSectionLines.First.Value; + reader.AdvanceTo(sectionStart.Start, buffer.End); + totalReadBytes += result.Buffer.Slice(0, sectionStart.Start).Length; + if (totalReadBytes >= Config.LogSizeLimit) + { + state.Error = LogParseState.ErrorCode.SizeLimit; + break; + } + } while (!(result.IsCompleted || result.IsCanceled || Config.Cts.IsCancellationRequested)); + reader.Complete(); + return state; + } + + private static async Task OnNewLineAsync(ReadOnlySequence line, ReadOnlySequence buffer, LinkedList> sectionLines, LogParseState state) + { + var currentProcessor = SectionParsers[state.Id]; + if (line.AsString().Contains(currentProcessor.EndTrigger, StringComparison.InvariantCultureIgnoreCase)) + { + await FlushAllLinesAsync(buffer, sectionLines, state).ConfigureAwait(false); + SectionParsers[state.Id].OnSectionEnd?.Invoke(state); + state.Id++; + return; + } + if (sectionLines.Count == 50) + await ProcessFirstLineInBufferAsync(buffer, sectionLines, state).ConfigureAwait(false); + sectionLines.AddLast(line); + } + + private static async Task FlushAllLinesAsync(ReadOnlySequence buffer, LinkedList> sectionLines, LogParseState state) + { + while (sectionLines.Count > 0 && state.Error == LogParseState.ErrorCode.None) + await ProcessFirstLineInBufferAsync(buffer, sectionLines, state).ConfigureAwait(false); + } + + private static async Task ProcessFirstLineInBufferAsync(ReadOnlySequence buffer, LinkedList> sectionLines, LogParseState state) + { + var currentProcessor = SectionParsers[state.Id]; + var firstSectionLine = sectionLines.First.Value.AsString(); + await currentProcessor.OnLineCheckAsync(firstSectionLine, state).ConfigureAwait(false); + if (state.Error != LogParseState.ErrorCode.None) + return; + + var section = buffer.Slice(sectionLines.First.Value.Start, sectionLines.Last.Value.End).AsString(); + currentProcessor.OnExtract?.Invoke(firstSectionLine, section, state); + sectionLines.RemoveFirst(); + + } + } +} diff --git a/CompatBot/LogParsing/LogParser.StateMachineGenerator.cs b/CompatBot/LogParsing/LogParser.StateMachineGenerator.cs new file mode 100644 index 00000000..4e540ebc --- /dev/null +++ b/CompatBot/LogParsing/LogParser.StateMachineGenerator.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using NReco.Text; + +namespace CompatBot.LogParsing +{ + using SectionAction = KeyValuePair>; + + internal partial class LogParser + { + private static readonly ReadOnlyCollection SectionParsers; + private static readonly Encoding Utf8 = new UTF8Encoding(false); + + static LogParser() + { + var parsers = new List(LogSections.Count); + foreach (var sectionDescription in LogSections) + { + var parser = new LogSectionParser + { + OnLineCheckAsync = sectionDescription.OnNewLineAsync ?? ((l, s) => Task.CompletedTask), + OnSectionEnd = sectionDescription.OnSectionEnd, + EndTrigger = Encoding.ASCII.GetString(Utf8.GetBytes(sectionDescription.EndTrigger)), + }; + // the idea here is to construct Aho-Corasick parser that will look for any data marker and run the associated regex to extract the data into state + if (sectionDescription.Extractors?.Count > 0) + { + var act = new AhoCorasickDoubleArrayTrie>(sectionDescription.Extractors.Select(extractorPair => + new SectionAction( + Encoding.ASCII.GetString(Utf8.GetBytes(extractorPair.Key)), + (buffer, state) => OnExtractorHit(buffer, extractorPair.Value, state) + ) + ), true); + parser.OnExtract = (line, buffer, state) => { act.ParseText(line, h => { h.Value(buffer, state); }); }; + } + parsers.Add(parser); + } + SectionParsers = parsers.AsReadOnly(); + } + + private static void OnExtractorHit(string buffer, Regex extractor, LogParseState state) + { + var matches = extractor.Matches(buffer); + foreach (Match match in matches) + foreach (Group group in match.Groups) + if (!string.IsNullOrEmpty(group.Name) && group.Name != "0" && !string.IsNullOrWhiteSpace(group.Value)) + { +#if DEBUG + Console.WriteLine($"regex {group.Name} = {group.Value}"); +#endif + state.WipCollection[group.Name] = Utf8.GetString(Encoding.ASCII.GetBytes(group.Value)); + } + } + + private delegate void OnNewLineDelegate(string line, string buffer, LogParseState state); + + private class LogSectionParser + { + public OnNewLineDelegate OnExtract; + public Func OnLineCheckAsync; + public Action OnSectionEnd; + public string EndTrigger; + } + } +} \ No newline at end of file diff --git a/CompatBot/LogParsing/POCOs/LogParseState.cs b/CompatBot/LogParsing/POCOs/LogParseState.cs new file mode 100644 index 00000000..fff4cd7d --- /dev/null +++ b/CompatBot/LogParsing/POCOs/LogParseState.cs @@ -0,0 +1,21 @@ +using System.Collections.Specialized; + +namespace CompatBot.LogParsing +{ + internal class LogParseState + { + public NameValueCollection CompleteCollection = null; + public NameValueCollection WipCollection = new NameValueCollection(); + public int Id = 0; + public ErrorCode Error = ErrorCode.None; + public string PiracyTrigger; + public string PiracyContext; + + public enum ErrorCode + { + None = 0, + PiracyDetected = 1, + SizeLimit = 2, + } + } +} \ No newline at end of file diff --git a/CompatBot/LogParsing/POCOs/LogSection.cs b/CompatBot/LogParsing/POCOs/LogSection.cs new file mode 100644 index 00000000..f03d0c0d --- /dev/null +++ b/CompatBot/LogParsing/POCOs/LogSection.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace CompatBot.LogParsing +{ + internal class LogSection + { + public string EndTrigger; + public Dictionary Extractors; + public Func OnNewLineAsync; + public Action OnSectionEnd; + } +} \ No newline at end of file diff --git a/CompatBot/LogParsing/SourceHandlers/GzipHandler.cs b/CompatBot/LogParsing/SourceHandlers/GzipHandler.cs new file mode 100644 index 00000000..81421867 --- /dev/null +++ b/CompatBot/LogParsing/SourceHandlers/GzipHandler.cs @@ -0,0 +1,45 @@ +using System; +using System.IO.Compression; +using System.IO.Pipelines; +using System.Net.Http; +using System.Threading.Tasks; +using DSharpPlus.Entities; + +namespace CompatBot.LogParsing.SourceHandlers +{ + public class GzipHandler: ISourceHandler + { + public Task CanHandleAsync(DiscordAttachment attachment) + { + return Task.FromResult(attachment.FileName.EndsWith(".log.gz", StringComparison.InvariantCultureIgnoreCase)); + } + + public async Task FillPipeAsync(DiscordAttachment attachment, PipeWriter writer) + { + using (var client = HttpClientFactory.Create()) + using (var downloadStream = await client.GetStreamAsync(attachment.Url).ConfigureAwait(false)) + using (var gzipStream = new GZipStream(downloadStream, CompressionMode.Decompress)) + { + try + { + int read; + FlushResult flushed; + do + { + var memory = writer.GetMemory(Config.MinimumBufferSize); + read = await gzipStream.ReadAsync(memory, Config.Cts.Token); + writer.Advance(read); //todo: test that .Advance(0) works as expected + flushed = await writer.FlushAsync(Config.Cts.Token).ConfigureAwait(false); + } while (read > 0 && !(flushed.IsCompleted || flushed.IsCanceled || Config.Cts.IsCancellationRequested)); + } + catch (Exception e) + { + Console.WriteLine(e); + writer.Complete(e); + return; + } + } + writer.Complete(); + } + } +} \ No newline at end of file diff --git a/CompatBot/LogParsing/SourceHandlers/ISourceHandler.cs b/CompatBot/LogParsing/SourceHandlers/ISourceHandler.cs new file mode 100644 index 00000000..b5ef3082 --- /dev/null +++ b/CompatBot/LogParsing/SourceHandlers/ISourceHandler.cs @@ -0,0 +1,12 @@ +using System.IO.Pipelines; +using System.Threading.Tasks; +using DSharpPlus.Entities; + +namespace CompatBot.LogParsing.SourceHandlers +{ + internal interface ISourceHandler + { + Task CanHandleAsync(DiscordAttachment attachment); + Task FillPipeAsync(DiscordAttachment attachment, PipeWriter writer); + } +} \ No newline at end of file diff --git a/CompatBot/LogParsing/SourceHandlers/PlainText.cs b/CompatBot/LogParsing/SourceHandlers/PlainText.cs new file mode 100644 index 00000000..34b7b793 --- /dev/null +++ b/CompatBot/LogParsing/SourceHandlers/PlainText.cs @@ -0,0 +1,43 @@ +using System; +using System.IO.Pipelines; +using System.Net.Http; +using System.Threading.Tasks; +using DSharpPlus.Entities; + +namespace CompatBot.LogParsing.SourceHandlers +{ + public class PlainTextHandler: ISourceHandler + { + public Task CanHandleAsync(DiscordAttachment attachment) + { + return Task.FromResult(attachment.FileName.EndsWith(".log", StringComparison.InvariantCultureIgnoreCase)); + } + + public async Task FillPipeAsync(DiscordAttachment attachment, PipeWriter writer) + { + using (var client = HttpClientFactory.Create()) + using (var stream = await client.GetStreamAsync(attachment.Url).ConfigureAwait(false)) + { + try + { + int read; + FlushResult flushed; + do + { + var memory = writer.GetMemory(Config.MinimumBufferSize); + read = await stream.ReadAsync(memory, Config.Cts.Token); + writer.Advance(read); //todo: test that .Advance(0) works as expected + flushed = await writer.FlushAsync(Config.Cts.Token).ConfigureAwait(false); + } while (read > 0 && !(flushed.IsCompleted || flushed.IsCanceled || Config.Cts.IsCancellationRequested)); + } + catch (Exception e) + { + Console.WriteLine(e); + writer.Complete(e); + return; + } + } + writer.Complete(); + } + } +} diff --git a/CompatBot/LogParsing/SourceHandlers/ZipHandler.cs b/CompatBot/LogParsing/SourceHandlers/ZipHandler.cs new file mode 100644 index 00000000..b4edd510 --- /dev/null +++ b/CompatBot/LogParsing/SourceHandlers/ZipHandler.cs @@ -0,0 +1,84 @@ +using System; +using System.Buffers; +using System.IO; +using System.IO.Compression; +using System.IO.Pipelines; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using CompatBot.Utils; +using DSharpPlus.Entities; + +namespace CompatBot.LogParsing.SourceHandlers +{ + public class ZipHandler: ISourceHandler + { + private static readonly ArrayPool bufferPool = ArrayPool.Create(1024, 16); + + public async Task CanHandleAsync(DiscordAttachment attachment) + { + if (!attachment.FileName.EndsWith(".zip", StringComparison.InvariantCultureIgnoreCase)) + return false; + + try + { + using (var client = HttpClientFactory.Create()) + using (var stream = await client.GetStreamAsync(attachment.Url).ConfigureAwait(false)) + { + var buf = bufferPool.Rent(1024); + var read = await stream.ReadBytesAsync(buf).ConfigureAwait(false); + var firstEntry = Encoding.ASCII.GetString(new ReadOnlySpan(buf, 0, read)); + var result = firstEntry.Contains(".log", StringComparison.InvariantCultureIgnoreCase); + bufferPool.Return(buf); + return result; + } + } + catch (Exception e) + { + Console.WriteLine(e.Message); + return false; + } + } + + public async Task FillPipeAsync(DiscordAttachment attachment, PipeWriter writer) + { + try + { + using (var fileStream = new FileStream(Path.GetTempFileName(), FileMode.Create, FileAccess.ReadWrite, FileShare.Read, 16384, FileOptions.Asynchronous | FileOptions.RandomAccess | FileOptions.DeleteOnClose)) + { + using (var client = HttpClientFactory.Create()) + using (var downloadStream = await client.GetStreamAsync(attachment.Url).ConfigureAwait(false)) + await downloadStream.CopyToAsync(fileStream, 16384, Config.Cts.Token).ConfigureAwait(false); + fileStream.Seek(0, SeekOrigin.Begin); + using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Read)) + { + var logEntry = zipArchive.Entries.FirstOrDefault(e => e.Name.EndsWith(".log", StringComparison.InvariantCultureIgnoreCase)); + if (logEntry == null) + throw new InvalidOperationException("No zip entries that match the log criteria"); + + using (var zipStream = logEntry.Open()) + { + int read; + FlushResult flushed; + do + { + var memory = writer.GetMemory(Config.MinimumBufferSize); + read = await zipStream.ReadAsync(memory, Config.Cts.Token); + writer.Advance(read); //todo: test that .Advance(0) works as expected + flushed = await writer.FlushAsync(Config.Cts.Token).ConfigureAwait(false); + } while (read > 0 && !(flushed.IsCompleted || flushed.IsCanceled || Config.Cts.IsCancellationRequested)); + } + } + } + } + catch (Exception e) + { + Console.WriteLine(e); + writer.Complete(e); + return; + } + writer.Complete(); + } + } +} \ No newline at end of file diff --git a/CompatBot/Program.cs b/CompatBot/Program.cs new file mode 100644 index 00000000..5cc71a33 --- /dev/null +++ b/CompatBot/Program.cs @@ -0,0 +1,97 @@ +using System; +using System.Threading.Tasks; +using CompatBot.Commands; +using CompatBot.Converters; +using CompatBot.Database; +using CompatBot.EventHandlers; +using DSharpPlus; +using DSharpPlus.CommandsNext; +using DSharpPlus.Entities; + +namespace CompatBot +{ + internal static class Program + { + internal static async Task Main(string[] args) + { + if (string.IsNullOrEmpty(Config.Token)) + { + Console.WriteLine("No token was specified."); + return; + } + + if (!await DbImporter.UpgradeAsync(BotDb.Instance, Config.Cts.Token)) + return; + + + var config = new DiscordConfiguration + { + Token = Config.Token, + TokenType = TokenType.Bot, + UseInternalLogHandler = true, + //LogLevel = LogLevel.Debug, + }; + + using (var client = new DiscordClient(config)) + { + var commands = client.UseCommandsNext(new CommandsNextConfiguration {StringPrefixes = new[] {Config.CommandPrefix}}); + commands.RegisterConverter(new CustomDiscordChannelConverter()); + commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); + commands.RegisterCommands(); + + client.Ready += async r => + { + Console.WriteLine("Bot is ready to serve!"); + Console.WriteLine(); + Console.WriteLine($"Bot user id : {r.Client.CurrentUser.Id} ({r.Client.CurrentUser.Username})"); + Console.WriteLine($"Bot admin id : {Config.BotAdminId} ({(await r.Client.GetUserAsync(Config.BotAdminId)).Username})"); + Console.WriteLine(); + Console.WriteLine("Checking starbucks backlog..."); + await r.Client.CheckBacklog().ConfigureAwait(false); + Console.WriteLine("Starbucks checked."); + }; + client.MessageReactionAdded += Starbucks.Handler; + + client.MessageCreated += AntipiracyMonitor.OnMessageCreated; // should be first + client.MessageCreated += ProductCodeLookup.OnMessageMention; + client.MessageCreated += LogInfoHandler.OnMessageCreated; + client.MessageCreated += LogsAsTextMonitor.OnMessageCreated; + + client.MessageUpdated += AntipiracyMonitor.OnMessageEdit; + + try + { + await client.ConnectAsync(); + } + catch (Exception e) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine(e.Message); + Console.ResetColor(); + Console.WriteLine("Terminating."); + return; + } + + if (args.Length > 1 && ulong.TryParse(args[1], out var channelId)) + { + Console.WriteLine("Found channelId: " + args[1]); + var channel = await client.GetChannelAsync(channelId).ConfigureAwait(false); + await channel.SendMessageAsync("Bot is up and running").ConfigureAwait(false); + } + + + while (!Config.Cts.IsCancellationRequested) + { + if (client.Ping > 1000) + await client.ReconnectAsync(); + await Task.Delay(TimeSpan.FromMinutes(1), Config.Cts.Token).ContinueWith(dt => {/* in case it was cancelled */}).ConfigureAwait(false); + } + } + Console.WriteLine("Exiting"); + } + } +} diff --git a/CompatBot/Properties/launchSettings.json b/CompatBot/Properties/launchSettings.json new file mode 100644 index 00000000..6ab23663 --- /dev/null +++ b/CompatBot/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "profiles": { + "default": { + "commandName": "Project", + "environmentVariables": { + "BotLogId": "", + "BotSpamId": "", + "BotAdminId": "", + "BotChannelId": "", + "BotRulesChannelId": "", + "Token": "" + } + } + } +} \ No newline at end of file diff --git a/CompatBot/Providers/ModProvider.cs b/CompatBot/Providers/ModProvider.cs new file mode 100644 index 00000000..ef5e1ac0 --- /dev/null +++ b/CompatBot/Providers/ModProvider.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CompatBot.Database; + +namespace CompatBot.Providers +{ + internal static class ModProvider + { + private static readonly Dictionary mods; + public static ReadOnlyDictionary Mods => new ReadOnlyDictionary(mods); + + static ModProvider() + { + mods = BotDb.Instance.Moderator.ToDictionary(m => m.DiscordId, m => m); + } + + public static bool IsMod(ulong userId) => mods.ContainsKey(userId); + + public static bool IsSudoer(ulong userId) => mods.TryGetValue(userId, out var mod) && mod.Sudoer; + + public static async Task AddAsync(ulong userId) + { + if (IsMod(userId)) + return false; + + var db = BotDb.Instance; + var result = await db.Moderator.AddAsync(new Moderator {DiscordId = userId}).ConfigureAwait(false); + await db.SaveChangesAsync().ConfigureAwait(false); + lock (mods) + { + if (IsMod(userId)) + return false; + mods[userId] = result.Entity; + } + return true; + } + + public static async Task RemoveAsync(ulong userId) + { + if (!mods.TryGetValue(userId, out var mod)) + return false; + + var db = BotDb.Instance; + db.Moderator.Remove(mod); + await db.SaveChangesAsync().ConfigureAwait(false); + lock (mods) + { + if (IsMod(userId)) + mods.Remove(userId); + else + return false; + } + return true; + } + + public static async Task MakeSudoerAsync(ulong userId) + { + if (!mods.TryGetValue(userId, out var mod) || mod.Sudoer) + return false; + + mod.Sudoer = true; + await BotDb.Instance.SaveChangesAsync().ConfigureAwait(false); + return true; + } + + public static async Task UnmakeSudoerAsync(ulong userId) + { + if (!mods.TryGetValue(userId, out var mod) || !mod.Sudoer) + return false; + + mod.Sudoer = false; + await BotDb.Instance.SaveChangesAsync().ConfigureAwait(false); + return true; + } + } +} diff --git a/CompatBot/Providers/PiracyStringProvider.cs b/CompatBot/Providers/PiracyStringProvider.cs new file mode 100644 index 00000000..68772460 --- /dev/null +++ b/CompatBot/Providers/PiracyStringProvider.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CompatBot.Database; +using Microsoft.EntityFrameworkCore; +using NReco.Text; + +namespace CompatBot.Providers +{ + internal static class PiracyStringProvider + { + private static readonly object SyncObj = new object(); + private static readonly List PiracyStrings; + private static AhoCorasickDoubleArrayTrie matcher; + + static PiracyStringProvider() + { + PiracyStrings = BotDb.Instance.Piracystring.Select(ps => ps.String).ToList(); + RebuildMatcher(); + } + + public static async Task AddAsync(string trigger) + { + if (PiracyStrings.Contains(trigger, StringComparer.InvariantCultureIgnoreCase)) + return false; + + lock (SyncObj) + { + if (PiracyStrings.Contains(trigger, StringComparer.InvariantCultureIgnoreCase)) + return false; + + PiracyStrings.Add(trigger); + RebuildMatcher(); + } + var db = BotDb.Instance; + await db.Piracystring.AddAsync(new Piracystring {String = trigger}).ConfigureAwait(false); + await db.SaveChangesAsync().ConfigureAwait(false); + return true; + } + + public static async Task RemoveAsync(int id) + { + var db = BotDb.Instance; + var dbItem = await db.Piracystring.FirstOrDefaultAsync(ps => ps.Id == id).ConfigureAwait(false); + if (dbItem == null) + return false; + + db.Piracystring.Remove(dbItem); + if (!PiracyStrings.Contains(dbItem.String)) + return false; + + lock (SyncObj) + { + if (!PiracyStrings.Remove(dbItem.String)) + return false; + + RebuildMatcher(); + } + + await db.SaveChangesAsync().ConfigureAwait(false); + return true; + } + + public static Task FindTriggerAsync(string str) + { + string result = null; + matcher?.ParseText(str, h => + { + result = h.Value; + return false; + }); + return Task.FromResult(result); + } + + private static void RebuildMatcher() + { + matcher = PiracyStrings.Count == 0 ? null : new AhoCorasickDoubleArrayTrie(PiracyStrings.ToDictionary(s => s, s => s)); + } + } +} \ No newline at end of file diff --git a/CompatBot/ResultFormatters/LogParserResultFormatter.cs b/CompatBot/ResultFormatters/LogParserResultFormatter.cs new file mode 100644 index 00000000..00790eb1 --- /dev/null +++ b/CompatBot/ResultFormatters/LogParserResultFormatter.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Specialized; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using CompatApiClient; +using CompatApiClient.POCOs; +using CompatBot.EventHandlers; +using CompatBot.LogParsing; +using DSharpPlus; +using DSharpPlus.Entities; + +namespace CompatBot.ResultFormatters +{ + internal static class LogParserResult + { + private static readonly Client compatClient = new Client(); + + // RPCS3 v0.0.3-3-3499d08 Alpha | HEAD + // RPCS3 v0.0.4-6422-95c6ac699 Alpha | HEAD + // RPCS3 v0.0.5-7104-a19113025 Alpha | HEAD + // RPCS3 v0.0.5-42b4ce13a Alpha | minor + private static readonly Regex BuildInfoInLog = new Regex(@"RPCS3 v(?(\d|\.)+)(-(?\d+))?-(?[0-9a-f]+) (?\w+) \| (?.*?)\r?$", + RegexOptions.Compiled | RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase | RegexOptions.Multiline); + + // rpcs3-v0.0.5-7105-064d0619_win64.7z + // rpcs3-v0.0.5-7105-064d0619_linux64.AppImage + private static readonly Regex BuildInfoInUpdate = new Regex(@"rpcs3-v(?(\d|\.)+)(-(?\d+))?-(?[0-9a-f]+)_", + RegexOptions.Compiled | RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase | RegexOptions.Singleline); + + public static async Task AsEmbedAsync(this LogParseState state, DiscordClient client, DiscordMessage message) + { + DiscordEmbedBuilder builder; + var collection = state.CompleteCollection ?? state.WipCollection; + if (collection?.Count > 0) + { + var gameInfo = await client.LookupGameInfoAsync(collection["serial"], false).ConfigureAwait(false); + builder = new DiscordEmbedBuilder(gameInfo); + if (state.Error == LogParseState.ErrorCode.PiracyDetected) + { + state.PiracyContext = state.PiracyContext.Sanitize(); + var msg = $"{message.Author.Mention}, you are being denied further support until you legally dump the game!\n" + + "Please note that the RPCS3 community and its developers do not support piracy!\n" + + "Most of the issues caused by pirated dumps is because they have been tampered with in such a way " + + "and therefore act unpredictably on RPCS3.\n" + + "If you need help obtaining legal dumps please read "; + builder.WithColor(Config.Colors.LogAlert) + .WithTitle("Pirated release detected") + .WithDescription(msg); + } + else + { + CleanupValues(collection); + BuildInfoSection(builder, collection); + BuildCpuSection(builder, collection); + BuildGpuSection(builder, collection); + BuildLibsSection(builder, collection); + await BuildNotesSectionAsync(builder, state, collection).ConfigureAwait(false); + } + } + else + { + builder = new DiscordEmbedBuilder + { + Description = "Log analysis failed, most likely cause is an empty log. Try reuploading a new copy.", + Color = Config.Colors.LogResultFailed, + }; + } + return builder.Build(); + } + + private static void CleanupValues(NameValueCollection items) + { + if (items["strict_rendering_mode"] == "true") + items["resolution_scale"] = "Strict Mode"; + if (items["spu_threads"] == "0") + items["spu_threads"] = "Auto"; + if (items["spu_secondary_cores"] != null) + items["thread_scheduler"] = items["spu_secondary_cores"]; + if (items["driver_manuf_new"] != null) + items["gpu_info"] = items["driver_manuf_new"]; + else if (items["vulkan_gpu"] != "\"\"") + items["gpu_info"] = items["vulkan_gpu"]; + else if (items["d3d_gpu"] != "\"\"") + items["gpu_info"] = items["d3d_gpu"]; + else if (items["driver_manuf"] != null) + items["gpu_info"] = items["driver_manuf"]; + else + items["gpu_info"] = "Unknown"; + if (items["driver_version_new"] != null) + items["gpu_info"] = items["gpu_info"] + " (" + items["driver_version_new"] + ")"; + else if (items["driver_version"] != null) + items["gpu_info"] = items["gpu_info"] + " (" + items["driver_version"] + ")"; + if (items["af_override"] is string af) + { + if (af == "0") + items["af_override"] = "Auto"; + else if (af == "1") + items["af_override"] = "Disabled"; + } + if (items["lib_loader"] is string libLoader) + { + var auto = libLoader.Contains("auto", StringComparison.InvariantCultureIgnoreCase); + var manual = libLoader.Contains("manual", StringComparison.InvariantCultureIgnoreCase); + if (auto && manual) + items["lib_loader"] = "Auto & manual select"; + else if (auto) + items["lib_loader"] = "Auto"; + else if (manual) + items["lib_loader"] = "Manual selection"; + } + if (items["win_path"] != null) + items["os_path"] = "Windows"; + else if (items["lin_path"] != null) + items["os_path"] = "Linux"; + else + items["os_path"] = "Unknown"; + if (items["library_list"] is string libs) + { + var libList = libs.Split('\n').Select(l => l.Trim(' ', '\t', '-', '\r', '[', ']')).Where(s => !string.IsNullOrEmpty(s)).ToList(); + items["library_list"] = libList.Count > 0 ? string.Join(", ", libList) : "None"; + } + + foreach (var key in items.AllKeys) + { + var value = items[key]; + if ("true".Equals(value, StringComparison.CurrentCultureIgnoreCase)) + value = "[x]"; + else if ("false".Equals(value, StringComparison.CurrentCultureIgnoreCase)) + value = "[ ]"; + items[key] = value.Sanitize(); + } + } + + private static void BuildInfoSection(DiscordEmbedBuilder builder, NameValueCollection items) + { + builder.AddField("Build Info", $"{items["build_and_specs"]}{Environment.NewLine}GPU: {items["gpu_info"]}"); + } + + private static void BuildCpuSection(DiscordEmbedBuilder builder, NameValueCollection items) + { + var content = new StringBuilder() + .AppendLine($"`PPU Decoder: {items["ppu_decoder"],21}`") + .AppendLine($"`SPU Decoder: {items["spu_decoder"],21}`") + .AppendLine($"`SPU Lower Thread Priority: {items["spu_lower_thread_priority"],7}`") + .AppendLine($"`SPU Loop Detection: {items["spu_loop_detection"],14}`") + .AppendLine($"`Thread Scheduler: {items["thread_scheduler"],16}`") + .AppendLine($"`Detected OS: {items["os_path"],21}`") + .AppendLine($"`SPU Threads: {items["spu_threads"],21}`") + .AppendLine($"`Force CPU Blit: {items["cpu_blit"] ?? "N/A",18}`") + .AppendLine($"`Hook Static Functions: {items["hook_static_functions"],11}`") + .AppendLine($"`Lib Loader: {items["lib_loader"],22}`") + .ToString(); + builder.AddField(items["custom_config"] == null ? "CPU Settings" : "Per-game CPU Settings", content, true); + } + + private static void BuildGpuSection(DiscordEmbedBuilder builder, NameValueCollection items) + { + var content = new StringBuilder() + .AppendLine($"`Renderer: {items["renderer"],24}`") + .AppendLine($"`Aspect ratio: {items["aspect_ratio"],20}`") + .AppendLine($"`Resolution: {items["resolution"],22}`") + .AppendLine($"`Resolution Scale: {items["resolution_scale"] ?? "N/A",16}`") + .AppendLine($"`Resolution Scale Threshold: {items["texture_scale_threshold"] ?? "N/A",6}`") + .AppendLine($"`Write Color Buffers: {items["write_color_buffers"],13}`") + .AppendLine($"`Use GPU texture scaling: {items["gpu_texture_scaling"],9}`") + .AppendLine($"`Anisotropic Filter: {items["af_override"] ?? "N/A",14}`") + .AppendLine($"`Frame Limit: {items["frame_limit"],21}`") + .AppendLine($"`Disable Vertex Cache: {items["vertex_cache"],12}`") + .ToString(); + builder.AddField(items["custom_config"] == null ? "GPU Settings" : "Per-game GPU Settings", content, true); + } + + private static void BuildLibsSection(DiscordEmbedBuilder builder, NameValueCollection items) + { + if (items["lib_loader"] is string libs && libs.Contains("manual", StringComparison.InvariantCultureIgnoreCase)) + builder.AddField("Selected Libraries", items["library_list"]); + } + + private static async Task BuildNotesSectionAsync(DiscordEmbedBuilder builder, LogParseState state, NameValueCollection items) + { + if (items["fatal_error"] is string fatalError) + builder.AddField("Fatal Error", $"`{fatalError}`"); + string notes = null; + if (state.Error == LogParseState.ErrorCode.SizeLimit) + notes += "Log was too large, showing last processed run"; + + // should be last check here + var updateInfo = await CheckForUpdateAsync(items).ConfigureAwait(false); + if (updateInfo != null) + notes += $"{Environment.NewLine}Outdated RPCS3 build detected"; + if (notes != null) + builder.AddField("Notes", notes); + + if (updateInfo != null) + await updateInfo.AsEmbedAsync(builder).ConfigureAwait(false); + } + + private static async Task CheckForUpdateAsync(NameValueCollection items) + { + if (!(items["build_and_specs"] is string buildAndSpecs)) + return null; + + var buildInfo = BuildInfoInLog.Match(buildAndSpecs.ToLowerInvariant()); + if (!buildInfo.Success || buildInfo.Groups["branch"].Value != "head") + return null; + + var updateInfo = await compatClient.GetUpdateAsync(Config.Cts.Token).ConfigureAwait(false); + var link = updateInfo.LatestBuild?.Windows?.Download ?? updateInfo.LatestBuild?.Linux?.Download; + if (string.IsNullOrEmpty(link)) + return null; + + var latestBuildInfo = BuildInfoInUpdate.Match(link.ToLowerInvariant()); + if (!latestBuildInfo.Success || buildInfo.Groups["commit"].Value == latestBuildInfo.Groups["commit"].Value) + return null; + + return updateInfo; + } + } +} \ No newline at end of file diff --git a/CompatBot/ResultFormatters/TitleInfoFormatter.cs b/CompatBot/ResultFormatters/TitleInfoFormatter.cs new file mode 100644 index 00000000..68cd0ee8 --- /dev/null +++ b/CompatBot/ResultFormatters/TitleInfoFormatter.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using CompatApiClient; +using CompatApiClient.POCOs; +using DSharpPlus.Entities; + +namespace CompatBot.ResultFormatters +{ + internal static class TitleInfoFormatter + { + private static readonly Dictionary StatusColors = new Dictionary(StringComparer.InvariantCultureIgnoreCase) + { + {"Nothing", Config.Colors.CompatStatusNothing}, + {"Loadable", Config.Colors.CompatStatusLoadable}, + {"Intro", Config.Colors.CompatStatusIntro}, + {"Ingame", Config.Colors.CompatStatusIngame}, + {"Playable", Config.Colors.CompatStatusPlayable}, + }; + + private static string ToUpdated(this TitleInfo info) + { + return DateTime.TryParseExact(info.Date, ApiConfig.DateInputFormat, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal, out var date) ? date.ToString(ApiConfig.DateOutputFormat) : null; + } + + private static string ToPrString(this TitleInfo info, string defaultString) + { + return (info.Pr ?? 0) == 0 ? defaultString : info.Pr.ToString(); + } + + public static string AsString(this TitleInfo info, string titleId) + { + if (info.Status == TitleInfo.Maintenance.Status) + return "API is undergoing maintenance, please try again later."; + + if (info.Status == TitleInfo.CommunicationError.Status) + return "Error communicating with compatibility API, please try again later."; + + if (StatusColors.TryGetValue(info.Status, out _)) + { + var title = info.Title.Trim(40); + return $"ID:{titleId,-9} Title:{title,-40} PR:{info.ToPrString("???"),-4} Status:{info.Status,-8} Updated:{info.ToUpdated(),-10}"; + } + + return $"Product code {titleId} was not found in compatibility database, possibly untested!"; + } + + public static DiscordEmbed AsEmbed(this TitleInfo info, string titleId, bool footer = true) + { + if (info.Status == TitleInfo.Maintenance.Status) + return new DiscordEmbedBuilder{Description = "API is undergoing maintenance, please try again later.", Color = Config.Colors.Maintenance}.Build(); + + if (info.Status == TitleInfo.CommunicationError.Status) + return new DiscordEmbedBuilder{Description = "Error communicating with compatibility API, please try again later.", Color = Config.Colors.Maintenance}.Build(); + + if (StatusColors.TryGetValue(info.Status, out var color)) + { + // apparently there's no formatting in the footer, but you need to escape everything in description; ugh + var pr = info.ToPrString(footer ? @"¯\_(ツ)_ /¯" : @"¯\\\_(ツ)\_ /¯"); + var desc = $"Status: {info.Status}, PR: {pr}, Updated: {info.ToUpdated()}"; + return new DiscordEmbedBuilder + { + Title = $"[{titleId}] {info.Title.Trim(200)}", + Url = $"https://forums.rpcs3.net/thread-{info.Thread}.html", + Description = footer ? null : desc, + Color = color, + }.WithFooter(footer ? desc : null) + .Build(); + } + else + { + var desc = string.IsNullOrEmpty(titleId) + ? "No product id was found; log might be corrupted, please reupload a new copy" + : $"Product code {titleId} was not found in compatibility database, possibly untested!"; + return new DiscordEmbedBuilder{Description = desc, Color = Config.Colors.CompatStatusUnknown}; + } + + } + + public static string AsString(this KeyValuePair resultInfo) + { + return resultInfo.Value.AsString(resultInfo.Key); + } + + public static DiscordEmbed AsEmbed(this KeyValuePair resultInfo) + { + return resultInfo.Value.AsEmbed(resultInfo.Key); + } + } +} diff --git a/CompatBot/ResultFormatters/UpdateInfoFormatter.cs b/CompatBot/ResultFormatters/UpdateInfoFormatter.cs new file mode 100644 index 00000000..c4a4454a --- /dev/null +++ b/CompatBot/ResultFormatters/UpdateInfoFormatter.cs @@ -0,0 +1,52 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using CompatApiClient; +using CompatApiClient.POCOs; +using DSharpPlus.Entities; + +namespace CompatBot.ResultFormatters +{ + internal static class UpdateInfoFormatter + { + private static readonly Client client = new Client(); + + public static async Task AsEmbedAsync(this UpdateInfo info, DiscordEmbedBuilder builder = null) + { + var justAppend = builder != null; + var build = info?.LatestBuild; + var pr = build?.Pr ?? "0"; + string url = null; + PrInfo prInfo = null; + + if (justAppend) + { + if (pr == "0") + pr = "PR #???"; + else + { + url = "https://github.com/RPCS3/rpcs3/pull/" + pr; + prInfo = await client.GetPrInfoAsync(pr, Config.Cts.Token).ConfigureAwait(false); + pr = $"PR #{pr} by {prInfo?.User?.login ?? "???"}"; + } + } + builder = builder ?? new DiscordEmbedBuilder {Title = pr, Url = url, Description = prInfo?.Title, Color = Config.Colors.DownloadLinks}; + return builder + .AddField($"Windows ({build?.Windows?.Datetime})", GetLinkMessage(build?.Windows?.Download, justAppend), justAppend) + .AddField($"Linux ({build?.Linux?.Datetime})", GetLinkMessage(build?.Linux?.Download, justAppend), justAppend); + } + + private static string GetLinkMessage(string link, bool simpleName) + { + if (string.IsNullOrEmpty(link)) + return "No link available"; + + var text = new Uri(link).Segments?.Last(); + if (simpleName && text.Contains('_')) + text = text.Split('_', 2)[0]; + + return $"⏬ [{text}]({link})"; + } + + } +} diff --git a/CompatBot/Utils/AutosplitResponseHelper.cs b/CompatBot/Utils/AutosplitResponseHelper.cs new file mode 100644 index 00000000..524bd158 --- /dev/null +++ b/CompatBot/Utils/AutosplitResponseHelper.cs @@ -0,0 +1,59 @@ +using System; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CompatApiClient; +using DSharpPlus.CommandsNext; +using DSharpPlus.Entities; + +namespace CompatBot.Utils +{ + public static class AutosplitResponseHelper + { + public static Task SendAutosplitMessageAsync(this CommandContext ctx, StringBuilder message, int blockSize = 2000, string blockEnd = "```", string blockStart = "```") + { + return ctx.Channel.SendAutosplitMessageAsync(message, blockSize, blockEnd, blockStart); + } + + public static Task SendAutosplitMessageAsync(this CommandContext ctx, string message, int blockSize = 2000, string blockEnd = "```", string blockStart = "```") + { + return ctx.Channel.SendAutosplitMessageAsync(message, blockSize, blockEnd, blockStart); + } + + public static async Task SendAutosplitMessageAsync(this DiscordChannel channel, StringBuilder message, int blockSize = 2000, string blockEnd = "```", string blockStart = "```") + { + if (message == null) + throw new ArgumentNullException(nameof(message)); + + await SendAutosplitMessageAsync(channel, message.ToString(), blockSize, blockEnd, blockStart).ConfigureAwait(false); + } + + public static async Task SendAutosplitMessageAsync(this DiscordChannel channel, string message, int blockSize = 2000, string blockEnd = "```", string blockStart = "```") + { + if (channel == null) + throw new ArgumentNullException(nameof(channel)); + + if (string.IsNullOrEmpty(message)) + return; + + blockEnd = blockEnd ?? ""; + blockStart = blockStart ?? ""; + var maxContentSize = blockSize - blockEnd.Length - blockStart.Length; + await channel.TriggerTypingAsync().ConfigureAwait(false); + var buffer = new StringBuilder(); + foreach (var line in message.Split(Environment.NewLine).Select(l => l.Trim(maxContentSize))) + { + if (buffer.Length + line.Length + blockEnd.Length > blockSize) + { + await channel.SendMessageAsync(buffer.Append(blockEnd).ToString()).ConfigureAwait(false); + await channel.TriggerTypingAsync().ConfigureAwait(false); + buffer.Clear().Append(blockStart); + } + else + buffer.AppendLine(); + buffer.Append(line); + } + await channel.SendMessageAsync(buffer.ToString()).ConfigureAwait(false); + } + } +} diff --git a/CompatBot/Utils/DiscordClientExtensions.cs b/CompatBot/Utils/DiscordClientExtensions.cs new file mode 100644 index 00000000..5e914f6f --- /dev/null +++ b/CompatBot/Utils/DiscordClientExtensions.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CompatApiClient; +using DSharpPlus; +using DSharpPlus.CommandsNext; +using DSharpPlus.Entities; +using DSharpPlus.Exceptions; + +namespace CompatBot.Utils +{ + public static class DiscordClientExtensions + { + public static async Task CreateDmAsync(this CommandContext ctx) + { + return ctx.Channel.IsPrivate ? ctx.Channel : await ctx.Member.CreateDmChannelAsync().ConfigureAwait(false); + } + + public static Task GetUserNameAsync(this CommandContext ctx, ulong userId, bool? forDmPurposes = null, string defaultName = "Unknown user") + { + return ctx.Client.GetUserNameAsync(ctx.Channel, userId, forDmPurposes, defaultName); + } + + public static async Task GetUserNameAsync(this DiscordClient client, DiscordChannel channel, ulong userId, bool? forDmPurposes = null, string defaultName = "Unknown user") + { + var isPrivate = forDmPurposes ?? channel.IsPrivate; + if (userId == 0) + return ""; + + try + { + return (await client.GetUserAsync(userId)).Username; + } + catch (NotFoundException) + { + return isPrivate ? $"@{userId}" : defaultName; + } + } + + public static async Task> GetMessagesBeforeAsync(this DiscordChannel channel, ulong beforeMessageId, int limit = 100, DateTime? timeLimit = null) + { + if (timeLimit > DateTime.UtcNow) + throw new ArgumentException(nameof(timeLimit)); + + var afterTime = timeLimit ?? DateTime.UtcNow.AddSeconds(-30); + var messages = await channel.GetMessagesBeforeAsync(beforeMessageId, limit).ConfigureAwait(false); + return messages.TakeWhile(m => m.CreationTimestamp > afterTime).ToList().AsReadOnly(); + } + + public static async Task ReportAsync(this DiscordClient client, string infraction, DiscordMessage message, string trigger, string context, bool needsAttention = false) + { + var getLogChannelTask = client.GetChannelAsync(Config.BotLogId); + var embedBuilder = MakeReportTemplate(infraction, message, needsAttention); + var reportText = string.IsNullOrEmpty(trigger) ? "" : $"Triggered by: `{trigger}`{Environment.NewLine}"; + if (!string.IsNullOrEmpty(context)) + reportText += $"Triggered in: ```{context.Sanitize()}```{Environment.NewLine}"; + embedBuilder.Description = reportText + embedBuilder.Description; + var logChannel = await getLogChannelTask.ConfigureAwait(false); + return await logChannel.SendMessageAsync(embed: embedBuilder.Build()).ConfigureAwait(false); + } + + public static async Task ReportAsync(this DiscordClient client, string infraction, DiscordMessage message, IEnumerable reporters, bool needsAttention = true) + { + var getLogChannelTask = client.GetChannelAsync(Config.BotLogId); + var embedBuilder = MakeReportTemplate(infraction, message, needsAttention); + embedBuilder.AddField("Reporters", string.Join(Environment.NewLine, reporters.Select(r => r.Mention))); + var logChannel = await getLogChannelTask.ConfigureAwait(false); + return await logChannel.SendMessageAsync(embed: embedBuilder.Build()).ConfigureAwait(false); + } + + private static DiscordEmbedBuilder MakeReportTemplate(string infraction, DiscordMessage message, bool needsAttention){ + var content = message.Content; + if (message.Attachments.Any()) + { + if (!string.IsNullOrEmpty(content)) + content += Environment.NewLine; + content += string.Join(Environment.NewLine, message.Attachments.Select(a => "📎 " + a.FileName)); + } + if (string.IsNullOrEmpty(content)) + content = "🤔 something fishy is going on here, there was no message or attachment"; + var result = new DiscordEmbedBuilder + { + Title = infraction, + Description = needsAttention ? "Not removed, requires attention! @here" : "Removed, doesn't require attention", + Color = needsAttention ? Config.Colors.LogAlert : Config.Colors.LogNotice + }.AddField("Violator", message.Author.Mention, true) + .AddField("Channel", message.Channel.Mention, true) + .AddField("Time (UTC)", message.CreationTimestamp.ToString("yyyy-MM-dd HH:mm:ss"), true) + .AddField("Content of the offending item", content); + if (needsAttention) + result.AddField("Link to the message", $"https://discordapp.com/channels/{message.Channel.Guild.Id}/{message.Channel.Id}/{message.Id}"); + return result; + } + } +} diff --git a/CompatBot/Utils/EmbedPager.cs b/CompatBot/Utils/EmbedPager.cs new file mode 100644 index 00000000..da00b782 --- /dev/null +++ b/CompatBot/Utils/EmbedPager.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Text; +using CompatApiClient; +using DSharpPlus.Entities; +using Remotion.Linq.Parsing; + +namespace CompatBot.Utils +{ + internal class EmbedPager + { + private const int MaxFieldLength = 1024; + private const int MaxTitleSize = 256; + private const int MaxFields = 25; + + public IEnumerable BreakInEmbeds(DiscordEmbedBuilder builder, IEnumerable lines, int maxLinesPerField = 10) + { + var fieldCount = 0; + foreach (var field in BreakInFieldContent(lines, maxLinesPerField)) + { + if (fieldCount == MaxFields) + { + yield return builder.Build(); + builder.ClearFields(); + fieldCount = 0; + } + builder.AddField(field.title.Trim(MaxTitleSize), field.content, true); + fieldCount++; + } + if (fieldCount > 0) + yield return builder.Build(); + } + + private IEnumerable<(string title, string content)> BreakInFieldContent(IEnumerable lines, int maxLinesPerField = 10) + { + if (maxLinesPerField < 1) + throw new ArgumentException("Expected a number greater than 0, but was " + maxLinesPerField, nameof(maxLinesPerField)); + + var buffer = new StringBuilder(); + var lineCount = 0; + string firstLine = null; + string lastLine = null; + foreach (var line in lines) + { + if (string.IsNullOrEmpty(firstLine)) + firstLine = line; + + if (lineCount == maxLinesPerField) + { + yield return (MakeTitle(firstLine, lastLine), buffer.ToString()); + buffer.Clear(); + lineCount = 0; + firstLine = line; + } + + if (buffer.Length + line.Length + Environment.NewLine.Length > MaxFieldLength) + { + if (buffer.Length + line.Length > MaxFieldLength) + { + if (buffer.Length == 0) + yield return (MakeTitle(line, line), line.Trim(MaxFieldLength)); + else + { + yield return (MakeTitle(firstLine, lastLine), buffer.ToString()); + buffer.Clear().Append(line); + lineCount = 1; + firstLine = line; + } + } + else + { + yield return (MakeTitle(firstLine, line), buffer.Append(line).ToString()); + buffer.Clear(); + lineCount = 0; + } + } + else + { + if (buffer.Length > 0) + buffer.AppendLine(); + buffer.Append(line); + lineCount++; + lastLine = line; + } + } + if (buffer.Length > 0) + yield return (MakeTitle(firstLine, lastLine), buffer.ToString()); + } + + private static string MakeTitle(string first, string last) + { + if (string.IsNullOrEmpty(first) || string.IsNullOrEmpty(last)) + return first + last; + + if (first == last) + return first; + + if (last.StartsWith(first)) + return $"{first} - {last}"; + + var commonPrefix = ""; + var maxPrefixSize = Math.Min(Math.Min(first.Length, last.Length), MaxTitleSize/2); + for (var i = 0; i < maxPrefixSize; i++) + { + if (first[i] == last[i]) + commonPrefix += first[i]; + else + return $"{commonPrefix}{first[i]}-{commonPrefix}{last[i]}"; + } + return commonPrefix; + } + } +} diff --git a/CompatBot/Utils/StreamExtensions.cs b/CompatBot/Utils/StreamExtensions.cs new file mode 100644 index 00000000..cefbf9a7 --- /dev/null +++ b/CompatBot/Utils/StreamExtensions.cs @@ -0,0 +1,21 @@ +using System.IO; +using System.Threading.Tasks; + +namespace CompatBot.Utils +{ + internal static class StreamExtensions + { + public static async Task ReadBytesAsync(this Stream stream, byte[] buffer) + { + var result = 0; + int read; + do + { + var remaining = buffer.Length - result; + read = await stream.ReadAsync(buffer, result, remaining).ConfigureAwait(false); + result += read; + } while (read > 0 && result < buffer.Length); + return result; + } + } +} diff --git a/CompatBot/Utils/StringUtils.cs b/CompatBot/Utils/StringUtils.cs new file mode 100644 index 00000000..e0b94782 --- /dev/null +++ b/CompatBot/Utils/StringUtils.cs @@ -0,0 +1,36 @@ +using System; +using System.Buffers; +using System.Text; + +namespace CompatBot.Utils +{ + internal static class StringUtils + { + public static string StripQuotes(this string str) + { + if (str == null || str.Length < 2) + return str; + + if (str.StartsWith('"') && str.EndsWith('"')) + return str.Substring(1, str.Length - 2); + return str; + } + + public static string AsString(this ReadOnlySequence buffer, Encoding encoding = null) + { + encoding = encoding ?? Encoding.ASCII; + if (buffer.IsSingleSegment) + return encoding.GetString(buffer.First.Span); + + void Splice(Span span, ReadOnlySequence sequence) + { + foreach (var segment in sequence) + { + encoding.GetChars(segment.Span, span); + span = span.Slice(segment.Length); + } + } + return string.Create((int)buffer.Length, buffer, Splice); + } + } +} diff --git a/LICENSE b/LICENSE index 19e30718..33dd7df3 100644 --- a/LICENSE +++ b/LICENSE @@ -6,9 +6,9 @@ Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. -(This is the first released version of the Lesser GPL. It also counts +[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence - the version number 2.1.) + the version number 2.1.] Preamble @@ -470,8 +470,9 @@ safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - {description} - Copyright (C) {year} {fullname} + RPCS3 Compatibility Bot provides moderation and entertainment + tools for discord channels of choice. + Copyright (C) 2018 RPCS3 project This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public @@ -488,17 +489,3 @@ convey the exclusion of warranty; and each file should have at least the Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your -school, if any, to sign a "copyright disclaimer" for the library, if -necessary. Here is a sample; alter the names: - - Yoyodyne, Inc., hereby disclaims all copyright interest in the - library `Frob' (a library for tweaking knobs) written by James Random - Hacker. - - {signature of Ty Coon}, 1 April 1990 - Ty Coon, President of Vice - -That's all there is to it! diff --git a/README.md b/README.md index 224766a6..6ab60f56 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,30 @@ -# discord-bot +RPCS3 Compatibility Bot reimplemented in C# for .NET Core +========================================================= -Dependencies: -* python3.6 or newer -* pip for python3 -* `$ pip install -U git+https://github.com/Rapptz/discord.py@rewrite#egg=discord.py[voice]` -* pyparsing for python3 (distro package or through pip) -* requests for python3 (distro package or through pip) -* peewee for python3 (distro package or through pip) +Development Requirements +------------------------ +* [.NET Core 2.1 SDK](https://www.microsoft.com/net/download/windows) or newer +* Any text editor, but Visual Studio 2017 or Visual Studio Code is recommended +Runtime Requirements +-------------------- +* [.NET Core 2.1 Runtime](https://www.microsoft.com/net/download/windows) or newer for compiled version +* [.NET Core 2.1 SDK](https://www.microsoft.com/net/download/windows) or newer to run from sources -Optional stuff for private testing: -* [create an app](https://discordapp.com/developers/applications/me) -* add a user bot to this new app (look at the bottom of the app page) - * notice the Bot User Token -* [add your new bot to your private server](https://discordapp.com/oauth2/authorize?client_id=BOTCLIENTID&scope=bot) -* change IDs in `bot_config.py` for your channels and users +How to Build +------------ +* Change configuration for test server in `CompatBot/Properties/launchSettings.json` +* Note that token could be set in the settings _or_ supplied as a launch argument (higher priority) +* If you've changed the database model, add a migration + * `$ cd CompatBot` + * `$ dotnet ef migrations add NAME` + * `$ cd ..` +* `$ cd CompatBot` +* `$ dotnet run [token]` -How to run: -* `$ python3 bot.py bot_user_token` \ No newline at end of file +How to Run in Production +------------------------ +* Change configuration if needed (probably just token) +* Put `bot.db` in `CompatBot/` +* `$ cd CompatBot` +* `$ dotnet run -c Release [token]` \ No newline at end of file diff --git a/api/__init__.py b/api/__init__.py deleted file mode 100644 index 5aca3993..00000000 --- a/api/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -__title__ = 'CompatAPI Python Wrapper' -__version__ = '1.0.0' -__build__ = 0x1 -__author__ = 'Roberto Anic Banic' -__license__ = 'GPLv3' -__copyright__ = 'Copyright 2017 Roberto Anic Banic' - -from .config import * -from .utils import * diff --git a/api/config.py b/api/config.py deleted file mode 100644 index a1083635..00000000 --- a/api/config.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -API Configuration File -""" -version = 1 - -datetime_input_format = "%Y-%m-%d" -datetime_output_format = "%Y-%m-%d" -datetime_compatlist_query_format = "%Y%m%d" -base_url = "https://rpcs3.net/compatibility" -newline_separator = "" - -return_codes = { - 0: { - "display_results": True, - "override_all": False, - "display_footer": True, - "info": "Results successfully retrieved." - }, - 1: { - "display_results": False, - "override_all": False, - "display_footer": True, - "info": "No results." - }, - 2: { - "display_results": True, - "override_all": False, - "display_footer": True, - "info": "No match was found, displaying results for: ***{lehvenstein}***." - }, - -1: { - "display_results": False, - "override_all": True, - "display_footer": False, - "info": "{requestor}: Internal error occurred, please contact Ani and Nicba1010" - }, - -2: { - "display_results": False, - "override_all": True, - "display_footer": False, - "info": "{requestor}: API is undergoing maintenance, please try again later." - }, - -3: { - "display_results": False, - "override_all": False, - "display_footer": False, - "info": "Illegal characters found, please try again with a different search term." - } -} - -default_amount = 1 -request_result_amount = { - 1: 15, - 2: 25, - 3: 50, - 4: 100 -} - -directions = { - "a": ("a", "asc", "ascending"), - "d": ("d", "desc", "descending") -} - -regions = { - "j": ("j", "ja", "japan", "JPN"), - "u": ("u", "us", "america", "USA"), - "e": ("e", "eu", "europe", "EU"), - "a": ("a", "asia", "ch", "china", "CHN"), - "k": ("k", "kor", "korea", "KOR"), - "h": ("h", "hk", "hong", "kong", "hongkong", "HK") -} - -statuses = { - "all": 0, - "playable": 1, - "ingame": 2, - "intro": 3, - "loadable": 4, - "nothing": 5 -} - -sort_types = { - "id": 1, - "title": 2, - "status": 3, - "date": 4 -} - -release_types = { - "b": ("b", "d", "disc", "bluray", "Blu-Ray"), - "n": ("n", "p", "psn", "PSN") -} diff --git a/api/request.py b/api/request.py deleted file mode 100644 index 2a881037..00000000 --- a/api/request.py +++ /dev/null @@ -1,200 +0,0 @@ -""" -ApiRequest class -""" - -from urllib.parse import urlencode -from datetime import datetime - -import requests - -import api -from api import datetime_compatlist_query_format, datetime_input_format, base_url, version -from api.response import ApiResponse - - -class ApiRequest(object): - """ - API Request builder object - """ - - def __init__(self, requestor=None) -> None: - self.requestor = requestor - self.custom_header = None - self.time_start = None - self.search = None - self.status = None - self.start = None - self.sort = None - self.date = None - self.release_type = None - self.region = None - self.amount = api.default_amount - self.amount_wanted = api.request_result_amount[api.default_amount] - - def set_search(self, search: str) -> 'ApiRequest': - """ - Adds the search string to the query. - :param search: string to search for - :return: ApiRequest object - """ - self.search = search - return self - - def set_custom_header(self, custom_header) -> 'ApiRequest': - """ - Sets a custom header. - :param custom_header: custom hedaer - :return: ApiRequest object - """ - self.custom_header = custom_header - - def set_status(self, status: int) -> 'ApiRequest': - """ - Adds status filter to the query. - :param status: status to filter by, see ApiConfig.statuses - :return: ApiRequest object - """ - try: - self.status = api.statuses[status] - except KeyError: - self.status = None - - return self - - def set_startswith(self, start: str) -> 'ApiRequest': - """ - Adds starting character filter to the query. - :param start: character to filter by - :return: ApiRequest object - """ - if len(start) != 1: - if start in ("num", "09"): - self.start = "09" - elif start in ("sym", "#"): - self.start = "sym" - else: - self.start = start - - return self - - def set_sort(self, sort_type, direction) -> 'ApiRequest': - """ - Adds sorting request to query. - :param sort_type: element to sort by, see ApiConfig.sort_types - :param direction: sorting direction, see ApiConfig.directions - :return: ApiRequest object - """ - for k, v in api.directions.items(): - if direction in v: - try: - self.sort = str(api.sort_types[sort_type]) + k - return self - except KeyError: - self.sort = None - return self - - return self - - def set_date(self, date: str) -> 'ApiRequest': - """ - Adds date filter to query. - :param date: date to filter by - :return: ApiRequest object - """ - try: - date = datetime.strptime(date, datetime_input_format) - self.date = datetime.strftime(date, datetime_compatlist_query_format) - except ValueError: - self.date = None - - return self - - def set_release_type(self, release_type: str) -> 'ApiRequest': - """ - Adds release type filter to query. - :param release_type: release type to filter by, see ApiConfig.release_type - :return: ApiRequest object - """ - for k, v in api.release_types.items(): - if release_type in v: - self.release_type = k - return self - - self.release_type = None - return self - - def set_region(self, region: str) -> 'ApiRequest': - """ - Adds region filter to query. - :param region: region to filter by, see ApiConfig.regions - :return: ApiRequest object - """ - for k, v in api.regions.items(): - if region in v: - self.region = k - return self - - self.region = None - return self - - def set_amount(self, amount: int) -> 'ApiRequest': - """ - Sets the desired result count and gets the closest available. - :param amount: desired result count, chooses closest available option, see ApiConfig.request_result_amount - :return: ApiRequest object - """ - if max(api.request_result_amount.values()) >= amount >= 1: - current_diff = -1 - - for k, v in api.request_result_amount.items(): - if v >= amount: - diff = v - amount - if diff < current_diff or current_diff == -1: - self.amount = k - current_diff = diff - - if current_diff != -1: - self.amount_wanted = amount - else: - self.amount_wanted = None - self.amount = api.default_amount - - return self - - def build_query(self) -> str: - """ - Builds the search query. - :return: the search query - """ - args = { "api": "v{}".format(version) } - if self.search is not None: - args["g"] = self.search - if self.status is not None: - args["s"] = self.status - if self.start is not None: - args["c"] = self.start - if self.sort is not None: - args["o"] = self.sort - if self.date is not None: - args["d"] = self.date - if self.release_type is not None: - args["t"] = self.release_type - if self.region is not None: - args["f"] = self.region - - url = base_url + "?" + urlencode(args) - return url - - def request(self) -> ApiResponse: - """ - Makes an API request to the API with the current request configuration. - :return: the API response - """ - print(self.build_query()) - self.time_start = api.system_time_millis() - return ApiResponse( - request=self, - data=requests.get(self.build_query()).content, - amount_wanted=self.amount_wanted, - custom_header=self.custom_header - ) diff --git a/api/response.py b/api/response.py deleted file mode 100644 index 516be229..00000000 --- a/api/response.py +++ /dev/null @@ -1,97 +0,0 @@ -""" -ApiResponse class -""" -import json -from typing import Dict, List - -from api import newline_separator, return_codes, system_time_millis, regions, release_types, sanitize_string -from bot_config import search_header -from .result import ApiResult - - -class ApiResponse(object): - """ - API Response object - """ - - # noinspection PyUnresolvedReferences - def __init__(self, request: 'ApiRequest', data: Dict, amount_wanted: int = None, custom_header: str = None) -> None: - self.request = request - self.results: List[ApiResult] = [] - - parsed_data = json.loads(data) - self.code = parsed_data["return_code"] - self.lehvenstein = parsed_data["search_term"] if self.code is 2 else '' - if return_codes[self.code]["display_results"]: - self.load_results(parsed_data["results"], amount=amount_wanted) - - self.time_end = system_time_millis() - self.custom_header = custom_header - - def load_results(self, data: Dict, amount: int = None) -> None: - """ - Loads the result object from JSON - :param data: data for the result objects - :param amount: desired amount to load - """ - for game_id, result_data in data.items(): - if amount is None or len(self.results) < amount: - self.results.append(ApiResult(game_id, result_data)) - else: - break - - def to_string(self) -> str: - """ - Makes a string representation of the object. - :return: string representation of the object - """ - return self.build_string().format( - requestor=self.request.requestor.mention, - search_string=sanitize_string(self.request.search), - request_url=self.request.build_query().replace("api=v1", "").replace("&&", "&").replace("?&","?"), - milliseconds=self.time_end - self.request.time_start, - amount=self.request.amount_wanted, - region="" if self.request.region is None else regions[self.request.region][-1], - media="" if self.request.release_type is None else release_types[self.request.release_type][-1], - lehvenstein=self.lehvenstein - ) - - def build_string(self) -> str: - """ - Builds a string representation of the object with placeholder. - :return: string representation of the object with placeholder - """ - header_string = search_header if self.custom_header is None else self.custom_header - results_string = "" - - results_string_part = "```\n" - for result in self.results: - result_string = result.to_string() - - if len(results_string_part) + len(result_string) + 4 > 2000: - results_string_part += "```" - results_string += results_string_part + newline_separator - results_string_part = "```\n" - - results_string_part += result_string + '\n' - - if results_string_part != "```\n": - results_string_part += "```" - results_string += results_string_part - - footer_string = "Retrieved from: *<{request_url}>* in {milliseconds} milliseconds!" - if return_codes[self.code]["display_results"]: - return "{}{}{}{}{}".format( - header_string + '\n' + return_codes[self.code]["info"], - newline_separator, - results_string, - newline_separator, - footer_string - ) - elif return_codes[self.code]["override_all"]: - return return_codes[self.code]["info"] - else: - return "{}{}".format( - header_string + '\n' + return_codes[self.code]["info"], - (newline_separator + footer_string) if return_codes[self.code]["display_footer"] else "" - ) diff --git a/api/result.py b/api/result.py deleted file mode 100644 index 4067bc39..00000000 --- a/api/result.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -ApiResult class -""" - -from datetime import datetime -from typing import Dict - -from discord import Embed - -from api import datetime_input_format, datetime_output_format, trim_string - - -class ApiResult(object): - """ - API Result object - """ - - # Taken from https://rpcs3.net/compatibility - STATUS_NOTHING = 0x455556 - STATUS_LOADABLE = 0xe74c3c - STATUS_INTRO = 0xe08a1e - STATUS_INGAME = 0xf9b32f - STATUS_PLAYABLE = 0x1ebc61 - STATUS_UNKNOWN = 0x3198ff - - status_map = dict({ - "Nothing" : STATUS_NOTHING, - "Loadable": STATUS_LOADABLE, - "Intro" : STATUS_INTRO, - "Ingame" : STATUS_INGAME, - "Playable": STATUS_PLAYABLE - }) - - def __init__(self, game_id: str, data: Dict) -> None: - self.game_id = game_id - self.title = data["title"] if "title" in data else None - self.status = data["status"] if "status" in data else None - self.date = datetime.strptime(data["date"], datetime_input_format) if "date" in data else None - self.thread = data["thread"] if "thread" in data else None - self.commit = data["commit"] if "commit" in data else None - self.pr = data["pr"] if "pr" in data and data["pr"] is not 0 else """???""" - - def to_string(self) -> str: - """ - Makes a string representation of the object. - :return: string representation of the object - """ - if self.status == "Maintenance": - return "API is undergoing maintenance, please try again later." - elif self.status in self.status_map: - return ("ID:{:9s} Title:{:40s} PR:{:4s} Status:{:8s} Updated:{:10s}".format( - self.game_id, - trim_string(self.title, 40), - self.pr, - self.status, - datetime.strftime(self.date, datetime_output_format) - )) - else: - return "Product code {} was not found in compatibility database, possibly untested!".format(self.game_id) - - def to_embed(self, infoInFooter=True) -> Embed: - """ - Makes an Embed representation of the object. - :return: Embed representation of the object - """ - if self.status == "Maintenance": - return Embed( - description="API is undergoing maintenance, please try again later.", - color=0xffff00 - ) - elif self.status in self.status_map: - desc = "Status: {}, PR: {}, Updated: {}".format( - self.status, - self.pr if self.pr != "???" else """¯\_(ツ)_/¯""", - datetime.strftime(self.date, datetime_output_format)) - result = Embed( - title="[{}] {}".format(self.game_id, trim_string(self.title, 200)), - url="https://forums.rpcs3.net/thread-{}.html".format(self.thread), - description=desc if not infoInFooter else None, - color=self.status_map[self.status]) - if infoInFooter: - return result.set_footer(text=desc) - else: - return result - - else: - desc = "No product id was found, log might be corrupted or tampered with" - if self.game_id is not None: - desc = "Product code {} was not found in compatibility database, possibly untested!".format( - self.game_id) - return Embed( - description=desc, - color=self.STATUS_UNKNOWN - ) diff --git a/api/utils.py b/api/utils.py deleted file mode 100644 index a5e3054d..00000000 --- a/api/utils.py +++ /dev/null @@ -1,17 +0,0 @@ -import time - - -def trim_string(string: str, length: int) -> str: - if len(string) > length: - return string[:length - 3] + "..." - else: - return string - - -def system_time_millis() -> int: - return int(round(time.time() * 1000)) - - -def sanitize_string(s: str) -> str: - return s.replace("`", "`\u200d").replace("@", "@\u200d") if s is not None else s - diff --git a/bot.py b/bot.py deleted file mode 100644 index 05ed5d95..00000000 --- a/bot.py +++ /dev/null @@ -1,1070 +0,0 @@ -import json -import os -import re -import subprocess -import sys -from datetime import datetime, timedelta -from random import randint, choice -from typing import List - -import requests -from discord import Message, Member, TextChannel, DMChannel, Forbidden, Reaction, User, Role, Embed, Emoji -from discord.ext.commands import Bot, Context, UserConverter, CommandError -from discord.utils import get -from requests import Response -from peewee import fn - -from api import newline_separator, directions, regions, statuses, release_types, trim_string -from api.request import ApiRequest -from api.utils import sanitize_string, trim_string -from bot_config import * -from bot_utils import get_code -from database import Moderator, init, PiracyString, Warning, Explanation -from log_analyzer import LogAnalyzer -from math_parse import NumericStringParser -from math_utils import limit_int -from stream_handlers import stream_text_log, stream_gzip_decompress, stream_zip_decompress, Deflate64Exception - -bot = Bot(command_prefix="!") -id_pattern = '(?P(?:[BPSUVX][CL]|P[ETU]|NP)[AEHJKPUIX][ABSM])[ \\-]?(?P\\d{5})' # see http://www.psdevwiki.com/ps3/Productcode -nsp = NumericStringParser() - -bot_channel: TextChannel = None -rules_channel: TextChannel = None -bot_log: TextChannel = None - -reaction_confirm: Emoji = None -reaction_failed: Emoji = None -reaction_deny: Emoji = None - -file_handlers = ( - { - 'ext': '.zip', - 'handler': stream_zip_decompress - }, - { - 'ext': '.log', - 'handler': stream_text_log - }, - { - 'ext': '.log.gz', - 'handler': stream_gzip_decompress - }, - # { - # 'ext': '.7z', - # 'handler': stream_7z_decompress - # } -) - - -async def generic_error_handler(ctx: Context, error: CommandError): - await react_with(ctx, reaction_failed) - await ctx.send(str(error)) - - -async def react_with(ctx: Context, reaction: Emoji): - try: - msg = ctx if isinstance(ctx, Message) else ctx.message - await msg.add_reaction(reaction) - except Exception as e: - print("Couldn't add a reaction: " + str(e)) - - -async def is_private_channel(ctx: Context, gay=True): - if isinstance(ctx.channel, DMChannel): - return True - else: - if gay: - message: Message = ctx.message - author: Member = message.author - await ctx.channel.send('{mention} https://i.imgflip.com/24qx11.jpg'.format(mention=author.mention)) - return False - - -@bot.event -async def on_ready(): - print('Logged in as:') - print(bot.user.name) - print(bot.user.id) - print('------') - global bot_channel - global bot_log - global rules_channel - global reaction_confirm - global reaction_failed - global reaction_deny - bot_channel = bot.get_channel(bot_channel_id) - rules_channel = bot.get_channel(bot_rules_channel_id) - bot_log = bot.get_channel(bot_log_id) - reaction_confirm = '👌' - reaction_failed = '⛔' - reaction_deny = '👮' - refresh_piracy_cache() - await refresh_moderated_messages() - print("Bot is ready to serve!") - - -async def refresh_moderated_messages(): - print("Checking moderated channels for missed new messages...") - for channel_id in user_moderatable_channel_ids: - channel = bot.get_channel(channel_id) - if channel is not None: - try: - async for msg in channel.history(after=(datetime.utcnow()-timedelta(hours=12))): - for reaction in msg.reactions: - if reaction.emoji == user_moderation_character: - usr = (await reaction.users(limit=1).flatten())[0] - await on_reaction_add(reaction, usr) - except Exception as e: - print("Uh oh: " + str(e)) - pass - - -@bot.event -async def on_reaction_add(reaction: Reaction, user: User): - message: Message = reaction.message - if message.author == bot.user or user == bot.user or await is_private_channel(message, gay=False): - #print("Author is bot or this is in DMs") - return - - role: Role - for role in message.author.roles: - if role.name.strip() in user_moderation_excused_roles: - #print(role.name + " is excused") - return - - #print("Checking for starbucks...") - if message.channel.id in user_moderatable_channel_ids: - #print("Checking for message expiration date...") - if (datetime.now() - message.created_at).total_seconds() < 12 * 60 * 60: - #print("Checking for " + user_moderation_character + " count...") - if reaction.emoji == user_moderation_character: - #print(reaction.count) - reporters = [] - user: Member - async for user in reaction.users(): - if user == bot.user: - #print("Found bot reaction, bailing out") - return - role: Role - for role in user.roles: - #print(role.name) - if role.name != '@everyone': - reporters.append(user) - break - #print(len(reporters)) - - if len(reporters) >= user_moderation_count_needed: - await react_with(message, user_moderation_character) - # noinspection PyTypeChecker - await report("User moderation report ⭐💵", trigger=None, trigger_context=None, message=message, reporters=reporters, - attention=True) - - -@bot.event -async def on_message_edit(before: Message, after: Message): - """ - OnMessageEdit event listener - :param before: message - :param after: message - """ - await piracy_check(after) - - -@bot.event -async def on_message(message: Message): - """ - OnMessage event listener - :param message: message - """ - # Self reply detect - if message.author == bot.user: - return - # Piracy detect - if await piracy_check(message): - return - # Command detect - try: - if message.content[0] == "!": - return await bot.process_commands(message) - except IndexError: - print("Empty message! Could still have attachments.") - - # Code reply - code_list = [] - for matcher in re.finditer(id_pattern, message.content, flags=re.IGNORECASE): - letter_part = str(matcher.group('letters')) - number_part = str(matcher.group('numbers')) - code = (letter_part + number_part).upper() - if code not in code_list: - code_list.append(code) - print(code) - if len(code_list) > 0: - max_len = 5 - if isinstance(message.channel, DMChannel): - max_len = min(len(code_list), 50) - for code in code_list[:(max_len)]: - info = get_code(code) - if info.status == "Maintenance": - await message.channel.send(info.to_string()) - break - else: - await message.channel.send(embed=info.to_embed()) - return - - # Log Analysis! - if len(message.attachments) > 0: - log = LogAnalyzer() - print("Attachments present, looking for log file...") - for attachment in filter(lambda a: any(e['ext'] in a.url for e in file_handlers), message.attachments): - for handler in file_handlers: - if attachment.url.endswith(handler['ext']): - print("Found log attachment, name: {name}".format(name=attachment.filename)) - with requests.get(attachment.url, stream=True) as response: - print("Opened request stream!") - # noinspection PyTypeChecker - try: - sent_log = False - result = None - for row in stream_line_by_line_safe(response, handler['handler']): - error_code = log.feed(row) - if error_code == LogAnalyzer.ERROR_SUCCESS: - continue - elif error_code == LogAnalyzer.ERROR_PIRACY: - await piracy_alert(message, log.get_trigger(), log.get_trigger_context()) - sent_log = True - break - elif error_code == LogAnalyzer.ERROR_OVERFLOW: - print("Possible Buffer Overflow Attack Detected!") - if result is None: - result = log.get_embed_report() - result = result.add_field(name="Notes", - value="Log was too large, showing last processed run") - break - elif error_code == LogAnalyzer.ERROR_STOP: - # await message.channel.send(log.get_text_report(), embed=log.product_info.to_embed()) - result = log.get_embed_report() - elif error_code == LogAnalyzer.ERROR_FAIL: - print("Log parsing failed") - break - if not sent_log: - if result is None: - await message.channel.send( - "Log analysis failed, most likely cause is a truncated/invalid log." - ) - print("Log analyzer didn't finish, probably a truncated/invalid log!") - else: - await message.channel.send(embed=result) - except Deflate64Exception: - await message.channel.send( - "Unsupported compression algorithm used.\n" - "\tAlgorithm name: Deflate64\n" - "\tAlternative: Deflate\n" - "\tOther alternatives: Default .log.gz file, Raw .log file\n" - ) - print("Stopping stream!") - del log - - -async def report(report_kind: str, trigger: str, trigger_context: str, message: Message, reporters: List[Member], attention=False): - author: Member = message.author - channel: TextChannel = message.channel - user: User = author._user - offending_content = message.content - - if len(message.attachments) > 0: - if offending_content is not None and offending_content != "": - offending_content += "\n" - for att in message.attachments: - offending_content += "\n📎 " + att.filename - if offending_content is None or offending_content == "": - offending_content = "🤔 something fishy is going on here, there was no message or attachment" - report_text = ("Triggered by: `" + trigger + "`\n") if trigger is not None else "" - report_text += ("Triggered in: ```" + trigger_context + "```\n") if trigger_context is not None else "" - report_text += "Not deleted/requires attention: @here" if attention else "Deleted/Doesn't require attention" - e = Embed( - title="Report for {}".format(report_kind), - description=report_text, - color=0xe74c3c if attention else 0xf9b32f - ) - e.add_field(name="Violator", value=message.author.mention) - e.add_field(name="Channel", value=channel.mention) - e.add_field(name="Time", value=message.created_at) - e.add_field(name="Contents of offending item", value=offending_content, inline=False) - if attention: - e.add_field( - name="Search query (since discord doesnt allow jump links <:panda_face:436991868547366913>)", - value="`from: {nick} during: {date} in: {channel} {text}`".format( - nick=user.name + "#" + user.discriminator, - date=message.created_at.strftime("%Y-%m-%d"), - channel=channel.name, - text=message.content.split(" ")[-1] - ), - inline=False - ) - if reporters is not None: - e.add_field(name="Reporters", value='\n'.join([x.mention for x in reporters])) - await bot_log.send("", embed=e) - - -# noinspection PyTypeChecker -async def piracy_check(message: Message): - for trigger in piracy_strings: - if trigger.lower() in message.content.lower(): # we should .lower() on trigger add ideally - try: - await message.delete() - except Forbidden: - print("Couldn't delete the moderated message") - await report("Piracy", trigger, None, message, None, attention=True) - return - await message.channel.send( - "{author} Please follow the {rules} and do not discuss " - "piracy on this server. Repeated offence may result in a ban.".format( - author=message.author.mention, - rules=rules_channel.mention - )) - await report("Piracy", trigger, None, message, None, attention=False) - await add_warning_for_user(message.channel, message.author.id, bot.user.id, - 'Pirated Phrase Mentioned', - str(message.created_at) + ' - ' + message.content) - return True - - -# noinspection PyTypeChecker -async def piracy_alert(message: Message, trigger: str, trigger_context: str): - try: - await message.delete() - await report("Pirated Release", trigger, trigger_context, message, None, attention=False) - except Forbidden: - print("Couldn't delete the moderated log attachment") - await report("Pirated Release", trigger, trigger_context, message, None, attention=True) - - await message.channel.send( - "Pirated release detected {author}!\n" - "**You are being denied further support until you legally dump the game!**\n" - "Please note that the RPCS3 community and its developers do not support piracy!\n" - "Most of the issues caused by pirated dumps is because they have been tampered with in such a way " - "and therefore act unpredictably on RPCS3.\n" - "If you need help obtaining legal dumps please read \n".format( - author=message.author.mention - ) - ) - await add_warning_for_user(message.channel, message.author.id, bot.user.id, - 'Pirated Release Detected', - str(message.created_at) + ' - ' + message.content + ' - ' + trigger) - - -def mask(string: str): - return ''.join("*" if i % 1 == 0 else char for i, char in enumerate(string, 1)) - - -def stream_line_by_line_safe(stream: Response, func: staticmethod): - buffer = '' - chunk_buffer = b'' - for chunk in func(stream): - try: - chunk_buffer += chunk - message = chunk_buffer.decode('UTF-8') - chunk_buffer = b'' - if '\n' in message: - parts = message.split('\n') - yield buffer + parts[0] - buffer = '' - for part in parts[1:-1]: - yield part - buffer += parts[-1] - elif len(buffer) > overflow_threshold or len(chunk_buffer) > overflow_threshold: - print('Possible overflow intended, piss off!') - break - else: - buffer += message - except UnicodeDecodeError as ude: - if ude.end == len(chunk_buffer): - pass - else: - print("{}\n{} {} {} {}".format(chunk_buffer, ude.reason, ude.start, ude.end, len(chunk_buffer))) - break - del chunk - del buffer - - -@bot.command() -async def math(ctx: Context, *args): - """Math, here you go Juhn""" - return await ctx.send(nsp.eval(''.join(map(str, args)))) - - -# noinspection PyShadowingBuiltins -@bot.command() -async def credits(ctx: Context): - """Author Credit""" - return await ctx.send("```\nMade by Roberto Anic Banic aka nicba1010!\n```") - - -# noinspection PyMissingTypeHints -@bot.command(pass_context=True) -async def c(ctx, *args): - """Searches the compatibility database, USE: !c searchterm """ - await compat_search(ctx, *args) - - -# noinspection PyMissingTypeHints -@bot.command(pass_context=True) -async def compat(ctx, *args): - """Searches the compatibility database, USE: !compat searchterm""" - await compat_search(ctx, *args) - - -# noinspection PyMissingTypeHints,PyMissingOrEmptyDocstring -async def compat_search(ctx, *args): - search_string = "" - for arg in args: - search_string += (" " + arg) if len(search_string) > 0 else arg - - search_string = trim_string(search_string, 40) - request = ApiRequest(ctx.message.author).set_search(search_string) - response = request.request() - await dispatch_message(response.to_string()) - - -# noinspection PyMissingTypeHints -@bot.command(pass_context=True) -async def top(ctx: Context, *args): - """ - Gets the x (default is 10 new) top games by specified criteria; order is flexible - Example usage: - !top 10 new - !top 10 new jpn - !top 10 playable - !top 10 new ingame eu - !top 10 old psn intro - !top 10 old loadable us bluray - - To see all filters do !filters - """ - request = ApiRequest(ctx.message.author) - age = "new" - amount = 10 - for arg in args: - arg = arg.lower() - if arg in ["old", "new"]: - age = arg - elif arg in ["nothing", "loadable", "intro", "ingame", "playable"]: - request.set_status(arg) - elif arg in ["bluray", "blu-ray", "disc", "psn", "b", "d", "n", "p"]: - request.set_release_type(arg.replace("-", "")) - elif arg.isdigit(): - amount = limit_int(int(arg), latest_limit) - else: - request.set_region(arg) - request.set_amount(amount) - if age == "old": - request.set_sort("date", "asc") - request.set_custom_header(oldest_header) - else: - request.set_sort("date", "desc") - request.set_custom_header(newest_header) - string = request.request().to_string() - await dispatch_message(string, True) - - -@bot.command() -async def filters(ctx: Context): - message = "**Sorting directions (not used in top command)**\n" - message += "Ascending\n```" + str(directions["a"]) + "```\n" - message += "Descending\n```" + str(directions["d"]) + "```\n" - message += "**Regions**\n" - message += "Japan\n```" + str(regions["j"]) + "```\n" - message += "US\n```" + str(regions["u"]) + "```\n" - message += "EU\n```" + str(regions["e"]) + "```\n" - message += "Asia\n```" + str(regions["a"]) + "```\n" - message += "Korea\n```" + str(regions["k"]) + "```\n" - message += "Hong-Kong\n```" + str(regions["h"]) + "```\n" - message += "**Statuses**\n" - message += "All\n```" + str(statuses["all"]) + "```\n" - message += "Playable\n```" + "playable" + "```\n" - message += "Ingame\n```" + "ingame" + "```\n" - message += "Intro\n```" + "intro" + "```\n" - message += "Loadable\n```" + "loadable" + "```\n" - message += "Nothing\n```" + "nothing" + "```\n" - message += "**Sort Types (not used in top command)**\n" - message += "ID\n```" + "id" + "```\n" - message += "Title\n```" + "title" + "```\n" - message += "Status\n```" + "status" + "```\n" - message += "Date\n```" + "date" + "```\n" - message += "**Release Types**\n" - message += "Blu-Ray\n```" + str(release_types["b"]) + "```\n" - message += "PSN\n```" + str(release_types["n"]) + "```\n" - await ctx.author.send(message) - - -async def dispatch_message(message: str, clean_up_first_line=False): - """ - Dispatches messages one by one divided by the separator defined in api.config - :param message: message to dispatch - """ - message_parts = message.split(newline_separator) - if clean_up_first_line: - message_parts[0] = message_parts[0].replace(" ", " ").replace(" ", " ") - for part in message_parts: - await bot_channel.send(part) - - -@bot.command() -async def latest(ctx: Context): - """Get the latest RPCS3 build link""" - latest_build = json.loads(requests.get("https://update.rpcs3.net/?c=somecommit").content)['latest_build'] - return await ctx.author.send( - "PR: {pr}\nWindows:\n\tTime: {win_time}\n\t{windows_url}\nLinux:\n\tTime: {linux_time}\n\t{linux_url}".format( - pr=latest_build['pr'], - win_time=latest_build['windows']['datetime'], - windows_url=latest_build['windows']['download'], - linux_time=latest_build['windows']['datetime'], - linux_url=latest_build['linux']['download'] - ) - ) - - -# User requests -# noinspection PyMissingTypeHints,PyMissingOrEmptyDocstring -@bot.command() -async def roll(ctx: Context, *args): - """Generates a random number between 0 and n (default 10)""" - n = 10 - if len(args) >= 1: - try: - n = int(args[0]) - except ValueError: - pass - await ctx.channel.send("You rolled a {}!".format(randint(0, n))) - - -# noinspection PyMissingTypeHints,PyMissingOrEmptyDocstring -@bot.command(name="8ball") -async def eight_ball(ctx: Context): - """Generates a random answer to your question""" - await ctx.send(choice([ - "Nah mate", "Ya fo sho", "Fo shizzle mah nizzle", "Yuuuup", "Nope", "Njet", "Da", "Maybe", "I don't know", - "I don't care", "Affirmative", "Sure", "Yeah, why not", "Most likely", "Sim", "Oui", "Heck yeah!", "Roger that", - "Aye!", "Yes without a doubt m8!", "Who cares", "Maybe yes, maybe not", "Maybe not, maybe yes", "Ugh", - "Probably", "Ask again later", "Error 404: answer not found", "Don't ask me that again", - "You should think twice before asking", "You what now?", "Bloody hell, answering that ain't so easy", - "Of course not", "Seriously no", "Noooooooooo", "Most likely not", "Não", "Non", "Hell no", "Absolutely not", - "Ask Neko", "Ask Ani", "I'm pretty sure that's illegal!", "<:cell_ok_hand:324618647857397760>", - "Don't be an idiot. YES.", - "What do *you* think?", "Only on Wednesdays" - ])) - - -async def is_sudo(ctx: Context): - message: Message = ctx.message - author: Member = message.author - sudo_user: Moderator = Moderator.get_or_none( - Moderator.discord_id == author.id, Moderator.sudoer == True - ) - if sudo_user is not None: - print("User " + author.display_name + " is sudoer, allowed!") - return True - else: - await react_with(ctx, reaction_deny) - await ctx.channel.send( - "{mention} is not a sudoer, this incident will be reported!".format(mention=author.mention) - ) - return False - - -async def is_mod(ctx: Context, report: bool = True): - message: Message = ctx.message - author: Member = message.author - mod_user: Moderator = Moderator.get_or_none( - Moderator.discord_id == author.id - ) - if mod_user is not None: - print("User " + author.display_name + " is moderator, allowed!") - return True - else: - if report: - await react_with(ctx, reaction_deny) - await ctx.channel.send("{mention} is not a mod, this incident will be reported!".format(mention=author.mention)) - return False - - -@bot.group() -async def sudo(ctx: Context): - """Sudo command group, used to manage moderators and sudoers.""" - if not await is_sudo(ctx): - ctx.invoked_subcommand = None - return - if ctx.invoked_subcommand is None: - await ctx.send('Invalid !sudo command passed...') - - -@sudo.command() -async def say(ctx: Context, *args): - """Basically says whatever you want it to say in a channel.""" - channel: TextChannel = bot.get_channel(int(args[0][2:-1])) \ - if args[0][:2] == '<#' and args[0][-1] == '>' \ - else ctx.channel - await channel.send(' '.join(args if channel.id == ctx.channel.id else args[1:])) - - -@sudo.command() -async def restart(ctx: Context, *args): - """Restarts bot and pulls newest commit.""" - process = subprocess.Popen(["git", "pull"], stdout=subprocess.PIPE) - await ctx.send(str(process.communicate()[0], "utf-8")) - await ctx.send('Restarting...') - os.execl(sys.executable, sys.argv[0], *sys.argv) - - -@sudo.group() -async def mod(ctx: Context): - """Mod subcommand for sudo mod group.""" - if ctx.invoked_subcommand is None: - await ctx.send('Invalid !sudo mod command passed...') - - -@mod.command() -async def add(ctx: Context, user: Member): - """Adds a new moderator.""" - moderator: Moderator = Moderator.get_or_none(Moderator.discord_id == user.id) - if moderator is None: - Moderator(discord_id=user.id).save() - await ctx.send( - "{mention} successfully added as moderator, you now have access to editing the piracy trigger list " - "and other useful things! I will send you the available commands to your message box!".format( - mention=user.mention - ) - ) - else: - await ctx.send( - "{mention} is already a moderator!".format( - mention=user.mention - ) - ) - - -@mod.command(name="del") -async def delete(ctx: Context, user: Member): - """Removes a moderator.""" - moderator: Moderator = Moderator.get_or_none(Moderator.discord_id == user.id) - if moderator is not None: - if moderator.discord_id != bot_admin_id: - if moderator.delete_instance(): - await ctx.send( - "{mention} removed as moderator!".format( - mention=user.mention - ) - ) - else: - await ctx.send( - "Something went wrong!".format( - mention=user.mention - ) - ) - else: - await ctx.send( - "{author_mention} why would you even try this! Alerting {mention}!".format( - author_mention=ctx.message.author_mention.mention, - mention=ctx.message.server.get_member(bot_admin_id).mention - ) - ) - else: - await ctx.send( - "{mention} not found in moderators table!".format( - mention=user.mention - ) - ) - - -# noinspection PyShadowingBuiltins -@mod.command(name="list") -async def list_mods(ctx: Context): - """Lists all moderators.""" - buffer = '```\n' - for moderator in Moderator.select(): - row = '{username:<32s} | {sudo}\n'.format( - username=bot.get_user(moderator.discord_id).name, - sudo=('sudo' if moderator.sudoer else 'not sudo') - ) - if len(buffer) + len(row) + 3 > 2000: - await ctx.send(buffer + '```') - buffer = '```\n' - buffer += row - if len(buffer) > 4: - await ctx.send(buffer + '```') - - -@mod.command() -async def sudo(ctx: Context, user: Member): - """Makes a moderator a sudoer.""" - moderator: Moderator = Moderator.get_or_none(Moderator.discord_id == user.id) - if moderator is not None: - if moderator.sudoer is False: - moderator.sudoer = True - moderator.save() - await ctx.send( - "{mention} successfully granted sudo permissions!".format( - mention=user.mention - ) - ) - else: - await ctx.send( - "{mention} already has sudo permissions!".format( - mention=user.mention - ) - ) - else: - await ctx.send( - "{mention} does not exist in moderator list, please add as moderator with mod_add!".format( - mention=user.mention - ) - ) - - -@mod.command() -async def unsudo(ctx: Context, user: Member): - """Removes a moderator from sudoers.""" - message: Message = ctx.message - author: Member = message.author - moderator: Moderator = Moderator.get_or_none(Moderator.discord_id == user.id) - if moderator is not None: - if moderator.discord_id != bot_admin_id: - if moderator.sudoer is True: - moderator.sudoer = False - moderator.save() - await ctx.send( - "Successfully took away sudo permissions from {mention}".format( - mention=user.mention - ) - ) - else: - await ctx.send( - "{mention} already doesn't have sudo permissions!".format( - mention=user.mention - ) - ) - else: - await ctx.send( - "{author_mention} why would you even try this! Alerting {mention}!".format( - author_mention=author.mention, - mention=bot.get_user(bot_admin_id).mention - ) - ) - else: - await ctx.send( - "{mention} does not exist in moderator list!".format( - mention=user.mention - ) - ) - - -@bot.group() -async def piracy(ctx: Context): - """Command used to manage piracy filters.""" - if not await is_mod(ctx): - ctx.invoked_subcommand = None - return - - if await is_private_channel(ctx): - if ctx.invoked_subcommand is None: - await ctx.send('Invalid piracy command passed...') - else: - ctx.invoked_subcommand = None - - -# noinspection PyShadowingBuiltins -@piracy.command(name="list") -async def list_piracy(ctx: Context): - """Lists all filters.""" - buffer = '```\n' - for piracy_string in PiracyString.select(): - row = str(piracy_string.id).zfill(4) + ' | ' + piracy_string.string + '\n' - if len(buffer) + len(row) + 3 > 2000: - await ctx.send(buffer + '```') - buffer = '```\n' - buffer += row - if len(buffer) > 4: - await ctx.send(buffer + '```') - - -@piracy.command() -async def add(ctx: Context, trigger: str): - """Adds a filter.""" - piracy_string = PiracyString.get_or_none(PiracyString.string == trigger) - if piracy_string is None: - PiracyString(string=trigger).save() - await ctx.send("Item successfully saved!") - await list_piracy.invoke(ctx) - refresh_piracy_cache() - else: - await ctx.send("Item already exists at id {id}!".format(id=piracy_string.id)) - - -# noinspection PyShadowingBuiltins -@piracy.command() -async def delete(ctx: Context, id: int): - """Removes a filter.""" - piracy_string: PiracyString = PiracyString.get_or_none(PiracyString.id == id) # Column actually exists but hidden - if piracy_string is not None: - piracy_string.delete_instance() - await ctx.send("Item successfully deleted!") - await list_piracy.invoke(ctx) - refresh_piracy_cache() - else: - await ctx.send("Item does not exist!") - - -@bot.group() -async def warn(ctx: Context): - """Command used to issue and manage warnings. USE: !warn @user reason""" - if ctx.invoked_subcommand == bot.get_command("warn list") or await is_mod(ctx): - if ctx.invoked_subcommand is None: - args = ctx.message.content.split(' ')[1:] - user_id = int(args[0][3:-1] if args[0][2] == '!' else args[0][2:-1]) - user: User = bot.get_user(user_id) - reason: str = ' '.join(args[1:]) - if await add_warning_for_user(ctx, user_id, ctx.message.author.id, reason): - await react_with(ctx, reaction_confirm) - await list_warnings_for_user(ctx, user_id, user.name if user is not None else "unknown user") - else: - await react_with(ctx, reaction_failed) - await list_warnings_for_user(ctx, user_id, user.name if user is not None else "") - else: - ctx.invoked_subcommand = None - - -async def add_warning_for_user(ctx: Context, user_id: int, reporter_id: int, reason: str, full_reason: str = '') -> bool: - if reason is None: - await ctx.send("A reason needs to be provided...") - return False - - Warning(discord_id=user_id, issuer_id=reporter_id, reason=reason, full_reason=full_reason).save() - num_warnings: int = Warning.select().where(Warning.discord_id == user_id).count() - #print("Saved warning for " + str(user_id)) - await ctx.send("User warning saved! User currently has {} {}!".format( - num_warnings, - 'warning' if num_warnings % 10 == 1 and num_warnings % 100 != 11 else "warnings" - )) - return True - - -# noinspection PyShadowingBuiltins -@warn.command(name="list") -async def list_warnings(ctx: Context, user: str = None): - """Lists users with warnings, or all warnings for a given user.""" - if user is None: - if await is_mod(ctx): - await list_users_with_warnings(ctx) - else: - try: - discord_user = await UserConverter().convert(ctx, user) - except Exception: - discord_user = None - if discord_user is None: - if await is_mod(ctx): - await list_warnings_for_user(ctx, int(user[2:-1]), "unknown user") - else: - if discord_user == ctx.message.author or await is_mod(ctx): - await list_warnings_for_user(ctx, discord_user.id, discord_user.name) - - -async def list_users_with_warnings(ctx: Context): - is_private = await is_private_channel(ctx, gay=False) - quotes = "```" # if not is_private else "" - buffer = "Warning count per user:" + quotes + "\n" - for user_row in Warning.select(Warning.discord_id, fn.COUNT(Warning.reason).alias('num')).group_by(Warning.discord_id): - user_id = user_row.discord_id - user: User = bot.get_user(user_id) - if user is not None: - user_name = user.display_name - elif is_private: - user_name = "<@" + str(user_id) + ">" - else: - user_name = "unknown user" - # if is_private: - # row = "<@{}>: {}\n".format(user_id, user_row.num) - # else: - row = str(sanitize_string(user_name.ljust(25))) + ' | ' + \ - ((str(user_id).ljust(18) + ' | ') if is_private else "") + \ - str(user_row.num).rjust(2) + '\n' - if len(buffer) + len(row) + len(quotes) > 2000: - await ctx.send(buffer + quotes) - buffer = quotes + '\n' - buffer += row - if len(buffer) > 4: - await ctx.send(buffer + quotes) - -async def list_warnings_for_user(ctx: Context, user: User): - if user is None: - await ctx.send("A user to scan for needs to be provided...") - return - - await list_warnings_for_user(ctx, user.id, user.display_name) - -async def list_warnings_for_user(ctx: Context, user_id: int, user_name: str): - if Warning.select().where(Warning.discord_id == user_id).count() == 0: - await ctx.send(user_name + " has no warnings, is a standup citizen, and a pillar of this community") - return - - is_private = await is_private_channel(ctx, gay=False) and await is_mod(ctx, report=False) - buffer = 'Warning list for ' + sanitize_string(user_name) + ':\n```\n' - for warning in Warning.select().where(Warning.discord_id == user_id): - warning_issuer = bot.get_user(warning.issuer_id) - if warning_issuer is not None: - warning_issuer_name = warning_issuer.display_name - elif warning.issuer_id > 0: - warning_issuer_name = "<@" + str(warning.issuer_id) + ">" if is_private else "unknown mod" - else: - warning_issuer_name = "" - row = str(warning.id).zfill(5) + ' | ' + \ - warning_issuer_name.ljust(25) + ' | ' + \ - warning.reason + \ - (' | ' + warning.full_reason if is_private else '') + '\n' - if len(buffer) + len(row) + 3 > 2000: - await ctx.send(buffer + '```') - buffer = '```\n' - buffer += row - if len(buffer) > 4: - await ctx.send(buffer + '```') - - -# noinspection PyShadowingBuiltins -@warn.command() -async def remove(ctx: Context, id: int): - """Removes a warning.""" - warning: Warning = Warning.get_or_none(Warning.id == id) # Column actually exists but hidden - if warning is not None: - warning.delete_instance() - await ctx.send("Warning successfully deleted!") - user = bot.get_user(warning.discord_id) - if user is not None: - await list_warnings_for_user(ctx, user.id, user.display_name) - else: - await list_warnings_for_user(ctx, warning.discord_id, "unknown user") - else: - await ctx.send("Warning does not exist!") - - -@bot.group() -async def explain(ctx: Context): - """Command used to show and manage explanations. USE: !explain term""" - if ctx.invoked_subcommand is None: - args = ctx.message.content.split(' ')[1:] - if (len(args) == 0): - await ctx.send("Use !explain term") - return - term = args[0] - explanation = Explanation.get_or_none(Explanation.keyword == term) - if explanation is None: - await react_with(ctx, reaction_failed) - else: - await ctx.send(explanation.text) - - -@explain.command() -async def add(ctx: Context): - """Add new term with specified explanation. USE: !explain add """ - if not await is_mod(ctx): - return - - args = ctx.message.content.split(maxsplit=3) - if (len(args) != 4): - await react_with(ctx, reaction_failed) - return - - term = args[2] - text = args[3] - if Explanation.get_or_none(Explanation.keyword == term) is None: - try: - Explanation(keyword=term, text=text).save() - await react_with(ctx, reaction_confirm) - except Exception: - await react_with(ctx, reaction_failed) - else: - await react_with(ctx, reaction_failed) - await ctx.send("Term `" + term + "` already exists, use !explain update instead") - - -@add.error -async def add_error(ctx: Context, error: CommandError): - await generic_error_handler(ctx, error) - - -@explain.command() -async def list(ctx: Context): - """List all known terms that could be used for !explain command""" - buffer = 'Defined terms:\n' - for explanation in Explanation.select(Explanation.keyword).order_by(Explanation.keyword): - row = explanation.keyword + '\n' - if len(buffer) + len(row) > 2000: - await ctx.send(buffer) - buffer = '' - buffer += row - if len(buffer) > 4: - await ctx.send(buffer) - - -@explain.command() -async def update(ctx: Context): - """Update explanation for a given term. USE: !explain update """ - if not await is_mod(ctx): - return - - args = ctx.message.content.split(maxsplit=3) - if (len(args) != 4): - await react_with(ctx, reaction_failed) - return - - term = args[2] - text = args[3] - if Explanation.get_or_none(Explanation.keyword == term) is None: - await react_with(ctx, reaction_failed) - await ctx.send("Term `" + term + "` has not been defined yet") - else: - try: - (Explanation.update({Explanation.text: text}) - .where(Explanation.keyword == term) - ).execute() - await react_with(ctx, reaction_confirm) - except Exception: - await react_with(ctx, reaction_failed) - - -@update.error -async def add_error(ctx: Context, error: CommandError): - await generic_error_handler(ctx, error) - - -@explain.command() -async def remove(ctx: Context, *, term: str): - """Removes term explanation""" - if not await is_mod(ctx): - return - - if Explanation.get_or_none(Explanation.keyword == term) is None: - await react_with(ctx, reaction_failed) - await ctx.send("Term `" + term + "` is not defined") - else: - try: - (Explanation.delete().where(Explanation.keyword == term)).execute() - await react_with(ctx, reaction_confirm) - except Exception: - await react_with(ctx, reaction_failed) - - -@remove.error -async def remove_error(ctx: Context, error): - await generic_error_handler(ctx, error) - - -def refresh_piracy_cache(): - print("Refreshing piracy cache!") - piracy_strings.clear() - for piracy_string in PiracyString.select(): - piracy_strings.append(piracy_string.string) - - -print(sys.argv[1]) -init() -bot.run(sys.argv[1]) diff --git a/bot_config.py b/bot_config.py deleted file mode 100644 index 48f8824b..00000000 --- a/bot_config.py +++ /dev/null @@ -1,48 +0,0 @@ -""" -Variables: -{requestor} = Requestor Mention -{search_string} = Search String -{amount} = Amount Requested -{region} = Region -{media} = Blu-Ray/PSN -""" -search_header = "{requestor} searched for: ***{search_string}***" -newest_header = "{requestor} requested top {amount} newest {region} {media} updated games" -oldest_header = "{requestor} requested top {amount} oldest {region} {media} updated games" - -invalid_command_text = "Invalid command!" - -overflow_threshold = 1024 * 1024 * 256 -latest_limit = 15 - -boot_up_message = "Hello and welcome to CompatBot. \n" \ - "You can expect this message every time you crash this bot so please don't. \n" \ - "All jokes aside if you manage to crash it please message Nicba1010 with the cause of the crash. \n" \ - "Commands I would recommend using are:\n" \ - "```" \ - "!help\n" \ - "!help top\n" \ - "!top new 10 all playable\n" \ - "!filters (It's a MUST)\n" \ - "```" \ - "I wish everyone here all the best of luck with using this bot and I will strive to improve it as " \ - "often as possible.\n" \ - "*Roberto Anic Banic AKA Nicba1010\n" \ - "https://github.com/RPCS3/discord-bot" - -bot_channel_id = 291679908067803136 -bot_spam_id = 319224795785068545 -bot_log_id = 436972161572536329 -bot_rules_channel_id = 311894275015049216 -bot_admin_id = 267367850706993152 - -user_moderation_character = '☕' -user_moderatable_channel_ids = [272875751773306881, 319224795785068545] -user_moderation_count_needed = 5 -user_moderation_excused_roles = ['Administrator', 'Community Manager', 'Web Developer', 'Moderator', - 'Lead Graphics Developer', 'Lead Core Developer', 'Developers', 'Affiliated', - 'Contributors' - ] -piracy_strings = [ - -] diff --git a/bot_utils.py b/bot_utils.py deleted file mode 100644 index fc9accf2..00000000 --- a/bot_utils.py +++ /dev/null @@ -1,17 +0,0 @@ -from api.request import ApiRequest -from api.result import ApiResult - -def get_code(code: str) -> ApiResult: - """ - Gets the game data for a certain game code or returns None - :param code: code to get data for - :return: data or None - """ - result = ApiRequest().set_search(code).set_amount(10).request() - if result.code == -2: - return ApiResult("", dict({"status": "Maintenance"})) - elif len(result.results) >= 1: - for result in result.results: - if result.game_id == code: - return result - return ApiResult(code, dict({"status": "Unknown"})) diff --git a/database.py b/database.py deleted file mode 100644 index a3867438..00000000 --- a/database.py +++ /dev/null @@ -1,65 +0,0 @@ -from peewee import * -from playhouse.migrate import * - -from bot_config import bot_admin_id - -db = SqliteDatabase('bot.db') - - -class BaseModel(Model): - class Meta: - database = db - - -class Moderator(BaseModel): - discord_id = IntegerField(unique=True) - sudoer = BooleanField(default=False) - - -class PiracyString(BaseModel): - string = CharField(unique=True) - - -class Warning(BaseModel): - discord_id = IntegerField(index=True) - issuer_id = IntegerField(default=0) - reason = TextField() - full_reason = TextField() - - -class Explanation(BaseModel): - keyword = TextField(unique=True) - text = TextField() - -def init(): - with db: - with db.atomic() as tx: - db.get_tables() - db.create_tables([Moderator, PiracyString, Warning, Explanation]) - try: - Moderator.get(discord_id=bot_admin_id) - except DoesNotExist: - Moderator(discord_id=bot_admin_id, sudoer=True).save() - tx.commit() - - migrator = SqliteMigrator(db) - try: - with db.atomic() as tx: - migrate( - migrator.add_column('warning', 'issuer_id', IntegerField(default=0)), - ) - tx.commit() - print("Updated [warning] columns") - except Exception as e: - print(str(e)) - tx.rollback() - try: - with db.atomic() as tx: - migrate( - migrator.add_index('warning', ('discord_id',), False), - ) - tx.commit() - print("Updated [warning] indices") - except Exception as e: - print(str(e)) - tx.rollback() diff --git a/discord-bot-net.sln b/discord-bot-net.sln new file mode 100644 index 00000000..84c70479 --- /dev/null +++ b/discord-bot-net.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.27703.2035 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CompatBot", "CompatBot\CompatBot.csproj", "{6D9CA448-60C1-4D66-91D6-EC6C586508E6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CompatApiClient", "CompatApiClient\CompatApiClient.csproj", "{8AF3C23B-D695-4391-A298-5BA4AAB8E13B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6D9CA448-60C1-4D66-91D6-EC6C586508E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6D9CA448-60C1-4D66-91D6-EC6C586508E6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6D9CA448-60C1-4D66-91D6-EC6C586508E6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6D9CA448-60C1-4D66-91D6-EC6C586508E6}.Release|Any CPU.Build.0 = Release|Any CPU + {8AF3C23B-D695-4391-A298-5BA4AAB8E13B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8AF3C23B-D695-4391-A298-5BA4AAB8E13B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8AF3C23B-D695-4391-A298-5BA4AAB8E13B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8AF3C23B-D695-4391-A298-5BA4AAB8E13B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D7696F56-AEAC-4D83-9BD8-BE0C122A5DCE} + EndGlobalSection +EndGlobal diff --git a/log_analyzer.py b/log_analyzer.py deleted file mode 100644 index 638b7cf1..00000000 --- a/log_analyzer.py +++ /dev/null @@ -1,379 +0,0 @@ -import re - -import itertools -from collections import deque -from api import sanitize_string, trim_string -from api.result import ApiResult -from bot_config import piracy_strings -from bot_utils import get_code - -SERIAL_PATTERN = re.compile('Serial: (?P[A-z]{4}\\d{5})') -LIBRARIES_PATTERN = re.compile('Load libraries:(?P.*)', re.DOTALL | re.MULTILINE) - - -class LogAnalyzer(object): - ERROR_SUCCESS = 0 - ERROR_PIRACY = 1 - ERROR_STOP = 2 - ERROR_OVERFLOW = -1 - ERROR_FAIL = -2 - ERROR_DEFLATE_64 = -3 - - def clear_results(self): - if self is None or self.parsed_data is None: - return self.ERROR_SUCCESS - if self.build_and_specs is None: - self.build_and_specs = self.parsed_data["build_and_specs"] - self.trigger = None - self.trigger_context = None - self.buffer = '' - self.buffer_lines.clear() - self.libraries = [] - self.parsed_data = { "build_and_specs": self.build_and_specs } - return self.ERROR_SUCCESS - - def piracy_check(self): - for trigger in piracy_strings: - lower_trigger = trigger.lower() - if lower_trigger in self.buffer.lower(): - self.trigger = trigger - for line in self.buffer_lines: - if lower_trigger in line.lower(): - self.trigger_context = line - return self.ERROR_PIRACY - return self.ERROR_SUCCESS - - def done(self): - return self.ERROR_STOP - - def done_and_reset(self): - self.phase_index = 0 - return self.ERROR_STOP - - def get_id(self): - try: - self.product_info = get_code(re.search(SERIAL_PATTERN, self.buffer).group('id')) - return self.ERROR_SUCCESS - except AttributeError: - self.product_info = ApiResult(None, dict({"status": "Unknown"})) - return self.ERROR_SUCCESS - - def parse_rsx(self): - if len(self.buffer_lines) < 4: - return False - if all(line.startswith('·!') for line in itertools.islice(self.buffer_lines, 4)) and \ - all('RSX: ' in line for line in itertools.islice(self.buffer_lines, 4)): - return True - return False - - def get_libraries(self): - try: - self.libraries = [lib.strip().replace('.sprx', '') - for lib - in re.search(LIBRARIES_PATTERN, self.buffer).group('libraries').strip()[1:].split('-')] - except KeyError as ke: - print(ke) - pass - return self.ERROR_SUCCESS - - """ - End Trigger - Regex - Message To Print - Special Return - """ - phase = ( - { - 'end_trigger': '·', - 'regex': re.compile('(?P.*)', flags=re.DOTALL | re.MULTILINE), - 'function': clear_results - }, - { - 'end_trigger': 'Core:', - 'regex': re.compile('Path: (?:(?P\\w:/)|(?P/)).*?\n' - '(?:.* custom config: (?P.*?)\n.*?)?', - flags=re.DOTALL | re.MULTILINE), - 'function': [get_id, piracy_check] - }, - { - 'end_trigger': 'VFS:', - 'regex': re.compile('Decoder: (?P.*?)\n.*?' - 'Threads: (?P.*?)\n.*?' - '(?:scheduler: (?P.*?)\n.*?)?' - 'Decoder: (?P.*?)\n.*?' - '(?:secondary cores: (?P.*?)\n.*?)?' - 'priority: (?P.*?)\n.*?' - 'SPU Threads: (?P.*?)\n.*?' - 'penalty: (?P.*?)\n.*?' - 'detection: (?P.*?)\n.*?' - '[Ll]oader: (?P.*?)\n.*?' - 'functions: (?P.*?)\n.*', - flags=re.DOTALL | re.MULTILINE), - 'function': get_libraries - }, - { - 'end_trigger': 'Video:', - 'regex': None, - 'function': None - }, - { - 'end_trigger': 'Audio:', - 'regex': re.compile('Renderer: (?P.*?)\n.*?' - 'Resolution: (?P.*?)\n.*?' - 'Aspect ratio: (?P.*?)\n.*?' - 'Frame limit: (?P.*?)\n.*?' - 'Write Color Buffers: (?P.*?)\n.*?' - 'VSync: (?P.*?)\n.*?' - 'Use GPU texture scaling: (?P.*?)\n.*?' - 'Strict Rendering Mode: (?P.*?)\n.*?' - 'Disable Vertex Cache: (?P.*?)\n.*?' - 'Blit: (?P.*?)\n.*?' - 'Resolution Scale: (?P.*?)\n.*?' - 'Anisotropic Filter Override: (?P.*?)\n.*?' - 'Minimum Scalable Dimension: (?P.*?)\n.*?' - '(?:D3D12|DirectX 12):\\s*\n\\s*Adapter: (?P.*?)\n.*?' - 'Vulkan:\\s*\n\\s*Adapter: (?P.*?)\n.*?', - flags=re.DOTALL | re.MULTILINE) - }, - { - 'end_trigger': 'Log:', - 'regex': None, - 'function': done - }, - { - 'end_trigger': 'Objects cleared...', - 'regex': re.compile('(?:' - 'RSX:(?:\\d|\\.|\\s|\\w|-)* (?P(?:\\d+\\.)*\\d+)\n[^\n]*?' - 'RSX: [^\n]+\n[^\n]*?' - 'RSX: (?P.*?)\n[^\n]*?' - 'RSX: Supported texel buffer size reported: (?P\\d*?) bytes' - ')|(?:' - 'GL RENDERER: (?P.*?)\n[^\n]*?' - 'GL VERSION:(?:\\d|\\.|\\s|\\w|-)* (?P(?:\\d+\\.)*\\d+)\n[^\n]*?' - 'RSX: [^\n]+\n[^\n]*?' - 'RSX: Supported texel buffer size reported: (?P\\d*?) bytes' - ')\n.*?', - flags=re.DOTALL | re.MULTILINE), - 'on_buffer_flush': parse_rsx, - 'function': done_and_reset - } - ) - - def __init__(self): - self.buffer = '' - self.buffer_lines = deque([]) - self.total_data_len = 0 - self.phase_index = 0 - self.build_and_specs = None - self.trigger = None - self.trigger_context = None - self.libraries = [] - self.parsed_data = {} - - def feed(self, data): - self.total_data_len += len(data) - if self.total_data_len > 32 * 1024 * 1024: - return self.ERROR_OVERFLOW - if self.phase[self.phase_index]['end_trigger'] in data \ - or self.phase[self.phase_index]['end_trigger'] is data.strip(): - error_code = self.process_data() - self.buffer = '' - self.buffer_lines.clear() - if error_code == self.ERROR_SUCCESS or error_code == self.ERROR_STOP: - self.phase_index += 1 - if error_code != self.ERROR_SUCCESS: - self.sanitize() - return error_code - else: - if len(self.buffer_lines) > 256: - error_code = self.process_data(True) - if error_code != self.ERROR_SUCCESS: - self.sanitize() - return error_code - self.buffer_lines.popleft() - self.buffer_lines.append(data) - return self.ERROR_SUCCESS - - def process_data(self, on_buffer_flush = False): - current_phase = self.phase[self.phase_index] - # if buffer was flushed and check function found relevant data, run regex - if on_buffer_flush: - try: - if current_phase['on_buffer_flush'] is not None: - if not current_phase['on_buffer_flush'](self): - return self.ERROR_SUCCESS - except KeyError: - pass - self.buffer = '\n'.join(self.buffer_lines) - if current_phase['regex'] is not None: - try: - regex_result = re.search(current_phase['regex'], self.buffer.strip() + '\n') - if regex_result is not None: - group_args = regex_result.groupdict() - self.parsed_data.update(group_args) - except AttributeError as ae: - print(ae) - print("Regex failed!") - return self.ERROR_FAIL - try: - # run funcitons only on end_trigger - if current_phase['function'] is not None and not on_buffer_flush: - if isinstance(current_phase['function'], list): - for func in current_phase['function']: - error_code = func(self) - if error_code != self.ERROR_SUCCESS: - return error_code - return self.ERROR_SUCCESS - else: - return current_phase['function'](self) - except KeyError: - pass - return self.ERROR_SUCCESS - - def sanitize(self): - result = {} - for k, v in self.parsed_data.items(): - r = sanitize_string(v) - if r is not None: - if r == "true": - r = "[x]" - elif r == "false": - r = "[ ]" - result[k] = r - self.parsed_data = result - libs = [] - for l in self.libraries: - libs.append(sanitize_string(l)) - self.libraries = libs - if self.trigger is not None: - self.trigger = sanitize_string(self.trigger) - if self.trigger_context is not None: - self.trigger_context = sanitize_string(trim_string(self.trigger_context, 256)) - - def get_trigger(self): - return self.trigger - - def get_trigger_context(self): - return self.trigger_context - - def process_final_data(self): - group_args = self.parsed_data - if 'strict_rendering_mode' in group_args and group_args['strict_rendering_mode'] == 'true': - group_args['resolution_scale'] = "Strict Mode" - if 'spu_threads' in group_args and group_args['spu_threads'] == '0': - group_args['spu_threads'] = 'Auto' - if 'spu_secondary_cores' in group_args and group_args['spu_secondary_cores'] is not None: - group_args['thread_scheduler'] = group_args['spu_secondary_cores'] - if 'driver_manuf_new' in group_args and group_args['driver_manuf_new'] is not None: - group_args['gpu_info'] = group_args['driver_manuf_new'] - elif 'vulkan_gpu' in group_args and group_args['vulkan_gpu'] != '""': - group_args['gpu_info'] = group_args['vulkan_gpu'] - elif 'd3d_gpu' in group_args and group_args['d3d_gpu'] != '""': - group_args['gpu_info'] = group_args['d3d_gpu'] - elif 'driver_manuf' in group_args and group_args['driver_manuf'] is not None: - group_args['gpu_info'] = group_args['driver_manuf'] - else: - group_args['gpu_info'] = 'Unknown' - if 'driver_version_new' in group_args and group_args["driver_version_new"] is not None: - group_args["gpu_info"] = group_args["gpu_info"] + " (" + group_args["driver_version_new"] + ")" - elif 'driver_version' in group_args and group_args["driver_version"] is not None: - group_args["gpu_info"] = group_args["gpu_info"] + " (" + group_args["driver_version"] + ")" - if 'af_override' in group_args: - if group_args['af_override'] == '0': - group_args['af_override'] = 'Auto' - elif group_args['af_override'] == '1': - group_args['af_override'] = 'Disabled' - - def get_text_report(self): - self.process_final_data() - additional_info = { - 'product_info': self.product_info.to_string(), - 'libs': ', '.join(self.libraries) if len(self.libraries) > 0 and self.libraries[0] != "]" else "None", - 'os': 'Windows' if 'win_path' in self.parsed_data and self.parsed_data['win_path'] is not None else 'Linux', - 'config': '\nUsing custom config!\n' if 'custom_config' in self.parsed_data and self.parsed_data['custom_config'] is not None else '' - } - additional_info.update(self.parsed_data) - return ( - '```' - '{product_info}\n' - '\n' - '{build_and_specs}' - 'GPU: {gpu_info}\n' - 'OS: {os}\n' - '{config}\n' - 'PPU Decoder: {ppu_decoder:>21s} | Thread Scheduler: {thread_scheduler}\n' - 'SPU Decoder: {spu_decoder:>21s} | SPU Threads: {spu_threads}\n' - 'SPU Lower Thread Priority: {spu_lower_thread_priority:>7s} | Hook Static Functions: {hook_static_functions}\n' - 'SPU Loop Detection: {spu_loop_detection:>14s} | Lib Loader: {lib_loader}\n' - '\n' - 'Selected Libraries: {libs}\n' - '\n' - 'Renderer: {renderer:>24s} | Frame Limit: {frame_limit}\n' - 'Resolution: {resolution:>22s} | Write Color Buffers: {write_color_buffers}\n' - 'Resolution Scale: {resolution_scale:>16s} | Use GPU texture scaling: {gpu_texture_scaling}\n' - 'Resolution Scale Threshold: {texture_scale_threshold:>6s} | Anisotropic Filter Override: {af_override}\n' - 'VSync: {vsync:>27s} | Disable Vertex Cache: {vertex_cache}\n' - '```' - ).format(**additional_info) - - def get_embed_report(self): - self.process_final_data() - lib_loader = self.parsed_data['lib_loader'] - if lib_loader is not None: - lib_loader = lib_loader.lower() - lib_loader_auto = 'auto' in lib_loader - lib_loader_manual = 'manual' in lib_loader - if lib_loader_auto and lib_loader_manual: - self.parsed_data['lib_loader'] = "Auto & manual select" - elif lib_loader_auto: - self.parsed_data['lib_loader'] = "Auto" - elif lib_loader_manual: - self.parsed_data['lib_loader'] = "Manual selection" - custom_config = 'custom_config' in self.parsed_data and self.parsed_data['custom_config'] is not None - self.parsed_data['os_path'] = 'Windows' if 'win_path' in self.parsed_data and self.parsed_data['win_path'] is not None else 'Linux' - result = self.product_info.to_embed(False).add_field( - name='Build Info', - value=( - '{build_and_specs}' - 'GPU: {gpu_info}' - ).format(**self.parsed_data), - inline=False - ).add_field( - name='CPU Settings' if not custom_config else 'Per-game CPU Settings', - value=( - '`PPU Decoder: {ppu_decoder:>21s}`\n' - '`SPU Decoder: {spu_decoder:>21s}`\n' - '`SPU Lower Thread Priority: {spu_lower_thread_priority:>7s}`\n' - '`SPU Loop Detection: {spu_loop_detection:>14s}`\n' - '`Thread Scheduler: {thread_scheduler:>16s}`\n' - '`Detected OS: {os_path:>21s}`\n' - '`SPU Threads: {spu_threads:>21s}`\n' - '`Force CPU Blit: {cpu_blit:>18s}`\n' - '`Hook Static Functions: {hook_static_functions:>11s}`\n' - '`Lib Loader: {lib_loader:>22s}`\n' - ).format(**self.parsed_data), - inline=True - ).add_field( - name='GPU Settings' if not custom_config else 'Per-game GPU Settings', - value=( - '`Renderer: {renderer:>24s}`\n' - '`Aspect ratio: {aspect_ratio:>20s}`\n' - '`Resolution: {resolution:>22s}`\n' - '`Resolution Scale: {resolution_scale:>16s}`\n' - '`Resolution Scale Threshold: {texture_scale_threshold:>6s}`\n' - '`Write Color Buffers: {write_color_buffers:>13s}`\n' - '`Use GPU texture scaling: {gpu_texture_scaling:>9s}`\n' - '`Anisotropic Filter: {af_override:>14s}`\n' - '`Frame Limit: {frame_limit:>21s}`\n' -# '`VSync: {vsync:>27s}`\n' - '`Disable Vertex Cache: {vertex_cache:>12s}`\n' - ).format(**self.parsed_data), - inline=True - ) - if 'manual' in self.parsed_data['lib_loader'].lower(): - result = result.add_field( - name="Selected Libraries", - value=', '.join(self.libraries) if len(self.libraries) > 0 and self.libraries[0] != "]" else "None", - inline=False - ) - return result diff --git a/math_parse.py b/math_parse.py deleted file mode 100644 index 6e0eb3a4..00000000 --- a/math_parse.py +++ /dev/null @@ -1,129 +0,0 @@ -from pyparsing import (Literal, CaselessLiteral, Word, Combine, Group, Optional, - ZeroOrMore, Forward, nums, alphas, oneOf) -import math -import operator - -__author__ = 'Paul McGuire & Roberto Anic Banic' -__version__ = '$Revision: 0.1 $' -__date__ = '$Date: 2018-02-04 $' -__source__ = '''http://pyparsing.wikispaces.com/file/view/fourFn.py -http://pyparsing.wikispaces.com/message/view/home/15549426 -''' -__note__ = ''' -All I've done is rewrap Paul McGuire's fourFn.py as a class, so I can use it -more easily in other places. -Added cmp -''' - - -class NumericStringParser(object): - ''' - Most of this code comes from the fourFn.py pyparsing example - - ''' - - def pushFirst(self, strg, loc, toks): - self.exprStack.append(toks[0]) - - def pushUMinus(self, strg, loc, toks): - if toks and toks[0] == '-': - self.exprStack.append('unary -') - - def __init__(self): - """ - expop :: '^' - multop :: '*' | '/' - addop :: '+' | '-' - integer :: ['+' | '-'] '0'..'9'+ - atom :: PI | E | real | fn '(' expr ')' | '(' expr ')' - factor :: atom [ expop factor ]* - term :: factor [ multop factor ]* - expr :: term [ addop term ]* - """ - point = Literal(".") - e = CaselessLiteral("E") - fnumber = Combine(Word("+-" + nums, nums) + - Optional(point + Optional(Word(nums))) + - Optional(e + Word("+-" + nums, nums))) - ident = Word(alphas, alphas + nums + "_$") - plus = Literal("+") - minus = Literal("-") - mult = Literal("*") - div = Literal("/") - lpar = Literal("(").suppress() - rpar = Literal(")").suppress() - addop = plus | minus - multop = mult | div - expop = Literal("^") - pi = CaselessLiteral("PI") - expr = Forward() - atom = ((Optional(oneOf("- +")) + - (ident + lpar + expr + rpar | pi | e | fnumber).setParseAction(self.pushFirst)) - | Optional(oneOf("- +")) + Group(lpar + expr + rpar) - ).setParseAction(self.pushUMinus) - # by defining exponentiation as "atom [ ^ factor ]..." instead of - # "atom [ ^ atom ]...", we get right-to-left exponents, instead of left-to-right - # that is, 2^3^2 = 2^(3^2), not (2^3)^2. - factor = Forward() - factor << atom + \ - ZeroOrMore((expop + factor).setParseAction(self.pushFirst)) - term = factor + \ - ZeroOrMore((multop + factor).setParseAction(self.pushFirst)) - expr << term + \ - ZeroOrMore((addop + term).setParseAction(self.pushFirst)) - # addop_term = ( addop + term ).setParseAction( self.pushFirst ) - # general_term = term + ZeroOrMore( addop_term ) | OneOrMore( addop_term) - # expr << general_term - self.bnf = expr - # map operator symbols to corresponding arithmetic operations - epsilon = 1e-12 - self.opn = {"+": operator.add, - "-": operator.sub, - "*": operator.mul, - "/": operator.truediv, - "^": operator.pow} - self.fn = {"sin": math.sin, - "cos": math.cos, - "tan": math.tan, - "exp": math.exp, - "abs": abs, - "sqrt": lambda a: math.sqrt(a), - "trunc": lambda a: int(a), - "round": round, - "sgn": lambda a: abs(a) > epsilon and self.cmp(a, 0) or 0} - - def evaluateStack(self, s): - op = s.pop() - if op == 'unary -': - return -self.evaluateStack(s) - if op in "+-*/^": - op2 = self.evaluateStack(s) - op1 = self.evaluateStack(s) - return self.opn[op](op1, op2) - elif op == "PI": - return math.pi # 3.1415926535 - elif op == "E": - return math.e # 2.718281828 - elif op in self.fn: - return self.fn[op](self.evaluateStack(s)) - elif op[0].isalpha(): - return 0 - else: - return float(op) - - def eval(self, num_string, parseAll=True): - self.exprStack = [] - results = self.bnf.parseString(num_string, parseAll) - val = self.evaluateStack(self.exprStack[:]) - return val - - def cmp(self, x, y): - """ - Replacement for built-in function cmp that was removed in Python 3 - - Compare the two objects x and y and return an integer according to - the outcome. The return value is negative if x < y, zero if x == y - and strictly positive if x > y. - """ - - return (x > y) - (x < y) diff --git a/math_utils.py b/math_utils.py deleted file mode 100644 index 96675b7d..00000000 --- a/math_utils.py +++ /dev/null @@ -1,9 +0,0 @@ -def limit_int(amount: int, high: int, low: int = 1) -> int: - """ - Limits an integer. - :param amount: amount - :param high: high limit - :param low: low limit - :return: limited integer - """ - return low if amount < low else (high if amount > high else amount) diff --git a/nuget.config b/nuget.config new file mode 100644 index 00000000..3f6b9774 --- /dev/null +++ b/nuget.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/stream_handlers.py b/stream_handlers.py deleted file mode 100644 index d8baffca..00000000 --- a/stream_handlers.py +++ /dev/null @@ -1,64 +0,0 @@ -import os -import uuid -import zipfile -import zlib - - -def download_file(stream, ext='.zip'): - filename = str(uuid.uuid4()) + ext - with open(filename, 'wb') as f: - for chunk in stream.iter_content(chunk_size=1024 * 1024): - if chunk: - f.write(chunk) - return filename - - -def stream_text_log(stream): - for chunk in stream.iter_content(chunk_size=1024): - yield chunk - - -def stream_gzip_decompress(stream): - dec = zlib.decompressobj(32 + zlib.MAX_WBITS) # offset 32 to skip the header - for chunk in stream: - try: - rv = dec.decompress(chunk) - if rv: - yield rv - del rv - except zlib.error as zlr: - pass - del dec - - -def stream_zip_decompress(stream): - filename = download_file(stream) - with SelfDeletingFile(filename): - try: - with zipfile.ZipFile(filename) as z: - for file in z.namelist(): - print(file) - if str(file).endswith('.log'): - with z.open(file) as f: - for line in f: - yield line - del line - except Exception as e: - if e.args[0] == 'compression type 9 (deflate64)': - raise Deflate64Exception - - -class SelfDeletingFile(object): - def __init__(self, filename): - self.filename = filename - - def __enter__(self): - print('Opening self deleting temporary file: ' + self.filename) - - def __exit__(self, type, value, traceback): - print('Removing self deleting temporary file: ' + self.filename) - os.remove(self.filename) - - -class Deflate64Exception(Exception): - pass