RPCS3 Compatibility Bot reimplemented in C# for .NET Core

RPCS3 Compatibility Bot reimplemented in C# for .NET Core

Current status of this PR:
* tested and targeted for .NET Core 2.1
* all functionality is either on par or improved compared to the python version
* compatibility with current bot.db should be preserved in all upgrade scenarios
* some bot management commands were changed (now under !sudo bot)
* standard help generator for the new discord client is ... different;
  compatibility with old format could be restored through custom formatter if needed
* everything has been split in more loosely tied components for easier extensibility and maintenance
* log parsing has been rewritten and should work ~2x as fast
This commit is contained in:
13xforever 2018-07-19 17:42:48 +05:00 committed by Roberto Anić Banić
parent 1709f445b4
commit 7fd7d09973
92 changed files with 5508 additions and 2435 deletions

63
.gitattributes vendored Normal file
View File

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

329
.gitignore vendored
View File

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

22
.vscode/launch.json vendored
View File

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

View File

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

111
CompatApiClient/Client.cs Normal file
View File

@ -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<string, PrInfo> prInfoCache = new Dictionary<string, PrInfo>();
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<CompatResult> 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<CompatResult>(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<UpdateInfo> 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<UpdateInfo>(formatters, cancellationToken).ConfigureAwait(false);
}
catch (Exception e)
{
PrintError(e);
result = new UpdateInfo { ReturnCode = -2 };
}
return result;
}
public async Task<PrInfo> 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<PrInfo>(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();
}
}
}

View File

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<LangVersion>latest</LangVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="5.2.6" />
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
</ItemGroup>
</Project>

View File

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

View File

@ -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<ICompressor> Compressors { get; }
public static readonly string PostCompressionFlag = "X-Set-Content-Encoding";
public static readonly string[] DefaultContentEncodings = { "gzip", "deflate" };
public static readonly string DefaultAcceptEncodings = "gzip, deflate";
private 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<HttpResponseMessage> 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;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,12 @@
using System.IO;
using System.Threading.Tasks;
namespace CompatApiClient.Compression
{
public interface ICompressor
{
string EncodingType { get; }
Task<long> CompressAsync(Stream source, Stream destination);
Task<long> DecompressAsync(Stream source, Stream destination);
}
}

View File

@ -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<string, string> propertyNameResolver;
public JsonContractResolver()
: this(NamingStyles.Dashed)
{
}
public JsonContractResolver(Func<string, string> 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;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<KeyValuePair<string, string>> parameters)
{
var builder = new StringBuilder();
foreach (var param in parameters)
{
if (builder.Length > 0)
builder.Append('&');
builder.Append(Uri.EscapeDataString(param.Key));
builder.Append('=');
builder.Append(Uri.EscapeDataString(param.Value));
}
return AddQueryValue(uri, builder.ToString());
}
public static Uri SetQueryParameter(this Uri uri, string name, string value)
{
var parameters = ParseQueryString(uri);
parameters[name] = value;
return SetQueryValue(uri, FormatUriParams(parameters));
}
public static Uri SetQueryParameters(this Uri uri, IEnumerable<KeyValuePair<string, string>> items)
{
var parameters = ParseQueryString(uri);
foreach (var item in items)
parameters[item.Key] = item.Value;
return SetQueryValue(uri, FormatUriParams(parameters));
}
public static 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);
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<char, string[]> 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<string, int> 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<string> 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;
}
}
}
}

View File

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

128
CompatBot/Commands/Misc.cs Normal file
View File

@ -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<string> EightBallAnswers = new List<string> {
"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<string> RateAnswers = new List<string>
{
"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<int> 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);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.1</TargetFramework>
<RootNamespace>CompatBot</RootNamespace>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<LangVersion>latest</LangVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DSharpPlus" Version="4.0.0-beta-00470" />
<PackageReference Include="DSharpPlus.CommandsNext" Version="4.0.0-beta-00470" />
<PackageReference Include="MathParser.org-mXparser" Version="4.2.0" />
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="5.2.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="2.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="2.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.1" />
<PackageReference Include="NReco.Text.AhoCorasickDoubleArrayTrie" Version="1.0.1" />
<PackageReference Include="System.IO.Pipelines" Version="4.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CompatApiClient\CompatApiClient.csproj" />
</ItemGroup>
</Project>

110
CompatBot/Config.cs Normal file
View File

@ -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<ulong> Channels = new List<ulong>
{
272875751773306881,
319224795785068545,
}.AsReadOnly();
public static readonly IReadOnlyCollection<string> RoleWhiteList = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase)
{
"Administrator",
"Community Manager",
"Web Developer",
"Moderator",
"Lead Graphics Developer",
"Lead Core Developer",
"Developers",
"Affiliated",
"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();
}
}
}
}

View File

@ -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<DiscordChannel>
{
private static Regex ChannelRegex { get; } = new Regex(@"^<#(\d+)>$", RegexOptions.ECMAScript | RegexOptions.Compiled);
public async Task<Optional<DiscordChannel>> ConvertAsync(string value, CommandContext ctx)
{
var guildList = new List<DiscordGuild>(ctx.Client.Guilds.Count);
if (ctx.Guild == null)
foreach (var g in ctx.Client.Guilds.Keys)
guildList.Add(await ctx.Client.GetGuildAsync(g).ConfigureAwait(false));
else
guildList.Add(ctx.Guild);
if (ulong.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var cid))
{
var result = (
from g in guildList
from ch in g.Channels
select ch
).FirstOrDefault(xc => xc.Id == cid);
var ret = result == null ? Optional<DiscordChannel>.FromNoValue() : Optional<DiscordChannel>.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<DiscordChannel>.FromValue(result) : Optional<DiscordChannel>.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<DiscordChannel>.FromValue(chn) : Optional<DiscordChannel>.FromNoValue();
}
}
}

View File

@ -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<BotDb> instance = new Lazy<BotDb>(() => new BotDb());
public static BotDb Instance => instance.Value;
public DbSet<Moderator> Moderator { get; set; }
public DbSet<Piracystring> Piracystring { get; set; }
public DbSet<Warning> Warning { get; set; }
public DbSet<Explanation> 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<Moderator>().HasIndex(m => m.DiscordId).IsUnique().HasName("moderator_discord_id");
modelBuilder.Entity<Piracystring>().HasIndex(ps => ps.String).IsUnique().HasName("piracystring_string");
modelBuilder.Entity<Warning>().HasIndex(w => w.DiscordId).HasName("warning_discord_id");
modelBuilder.Entity<Explanation>().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; }
}
}

View File

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

View File

@ -0,0 +1,88 @@
// <auto-generated />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnName("id");
b.Property<ulong>("DiscordId")
.HasColumnName("discord_id");
b.Property<bool>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnName("id");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnName("id");
b.Property<ulong>("DiscordId")
.HasColumnName("discord_id");
b.Property<string>("FullReason")
.IsRequired()
.HasColumnName("full_reason");
b.Property<string>("Reason")
.IsRequired()
.HasColumnName("reason");
b.HasKey("Id")
.HasName("id");
b.ToTable("warning");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -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<int>(nullable: false)
.Annotation("Sqlite:Autoincrement", true)
,
discord_id = table.Column<ulong>(nullable: false),
sudoer = table.Column<bool>(nullable: false)
},
constraints: table =>
{
table.PrimaryKey("id", x => x.id);
});
migrationBuilder.CreateTable(
name: "piracystring",
columns: table => new
{
id = table.Column<int>(nullable: false)
.Annotation("Sqlite:Autoincrement", true)
,
@string = table.Column<string>(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<int>(nullable: false)
.Annotation("Sqlite:Autoincrement", true)
,
discord_id = table.Column<ulong>(nullable: false),
reason = table.Column<string>(nullable: false),
full_reason = table.Column<string>(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");
}
}
}

View File

@ -0,0 +1,118 @@
// <auto-generated />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnName("id");
b.Property<string>("Keyword")
.IsRequired()
.HasColumnName("keyword");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnName("id");
b.Property<ulong>("DiscordId")
.HasColumnName("discord_id");
b.Property<bool>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnName("id");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnName("id");
b.Property<ulong>("DiscordId")
.HasColumnName("discord_id");
b.Property<string>("FullReason")
.IsRequired()
.HasColumnName("full_reason");
b.Property<ulong>("IssuerId")
.HasColumnName("issuer_id");
b.Property<string>("Reason")
.IsRequired()
.HasColumnName("reason");
b.HasKey("Id")
.HasName("id");
b.HasIndex("DiscordId")
.HasName("warning_discord_id");
b.ToTable("warning");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -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<ulong>(
name: "issuer_id",
table: "warning",
nullable: false,
defaultValue: 0ul);
migrationBuilder.CreateTable(
name: "explanation",
columns: table => new
{
id = table.Column<int>(nullable: false)
.Annotation("Sqlite:Autoincrement", true)
,
keyword = table.Column<string>(nullable: false),
text = table.Column<string>(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");
}
}
}

View File

@ -0,0 +1,122 @@
// <auto-generated />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnName("id");
b.Property<string>("Keyword")
.IsRequired()
.HasColumnName("keyword");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnName("id");
b.Property<ulong>("DiscordId")
.HasColumnName("discord_id");
b.Property<bool>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnName("id");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnName("id");
b.Property<ulong>("DiscordId")
.HasColumnName("discord_id");
b.Property<string>("FullReason")
.IsRequired()
.HasColumnName("full_reason");
b.Property<ulong>("IssuerId")
.HasColumnName("issuer_id");
b.Property<string>("Reason")
.IsRequired()
.HasColumnName("reason");
b.Property<long?>("Timestamp")
.HasColumnName("timestamp");
b.HasKey("Id")
.HasName("id");
b.HasIndex("DiscordId")
.HasName("warning_discord_id");
b.ToTable("warning");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -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<long>(
name: "timestamp",
table: "warning",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "timestamp",
table: "warning");
}
}
}

View File

@ -0,0 +1,120 @@
// <auto-generated />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnName("id");
b.Property<string>("Keyword")
.IsRequired()
.HasColumnName("keyword");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnName("id");
b.Property<ulong>("DiscordId")
.HasColumnName("discord_id");
b.Property<bool>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnName("id");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnName("id");
b.Property<ulong>("DiscordId")
.HasColumnName("discord_id");
b.Property<string>("FullReason")
.IsRequired()
.HasColumnName("full_reason");
b.Property<ulong>("IssuerId")
.HasColumnName("issuer_id");
b.Property<string>("Reason")
.IsRequired()
.HasColumnName("reason");
b.Property<long?>("Timestamp")
.HasColumnName("timestamp");
b.HasKey("Id")
.HasName("id");
b.HasIndex("DiscordId")
.HasName("warning_discord_id");
b.ToTable("warning");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,27 @@
using System;
using Microsoft.EntityFrameworkCore;
namespace CompatBot.Database
{
internal static class NamingConventionConverter
{
public static void ConfigureMapping(this ModelBuilder modelBuilder, Func<string, string> nameResolver)
{
if (nameResolver == null)
throw new ArgumentNullException(nameof(nameResolver));
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);
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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(@"(?<letters>(?:[BPSUVX][CL]|P[ETU]|NP)[AEHJKPUIX][ABSM])[ \-]?(?<numbers>\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<DiscordEmbed> 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<DiscordEmbed> 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);
}
}
}
}

View File

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

View File

@ -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<LogSection> LogSections = new List<LogSection>
{
new LogSection
{
Extractors = new Dictionary<string, Regex>
{
["RPCS3"] = new Regex(@"(?<build_and_specs>.*)\r?$", RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.ExplicitCapture),
},
EndTrigger = "·",
},
new LogSection
{
Extractors = new Dictionary<string, Regex>
{
["Serial:"] = new Regex(@"Serial: (?<serial>[A-z]{4}\d{5})\r?$", DefaultOptions),
["Path:"] = new Regex(@"Path: ((?<win_path>\w:/)|(?<lin_path>/[^/])).*?\r?$", DefaultOptions),
["custom config:"] = new Regex("custom config: (?<custom_config>.*?)\r?$", DefaultOptions),
},
OnNewLineAsync = PiracyCheckAsync,
EndTrigger = "Core:",
},
new LogSection
{
Extractors = new Dictionary<string, Regex>
{
["PPU Decoder:"] = new Regex("PPU Decoder: (?<ppu_decoder>.*?)\r?$", DefaultOptions),
["PPU Threads:"] = new Regex("Threads: (?<ppu_threads>.*?)\r?$", DefaultOptions),
["thread scheduler:"] = new Regex("scheduler: (?<thread_scheduler>.*?)\r?$", DefaultOptions),
["SPU Decoder:"] = new Regex("SPU Decoder: (?<spu_decoder>.*?)\r?$", DefaultOptions),
["secondary cores:"] = new Regex("secondary cores: (?<spu_secondary_cores>.*?)\r?$", DefaultOptions),
["priority:"] = new Regex("priority: (?<spu_lower_thread_priority>.*?)\r?$", DefaultOptions),
["SPU Threads:"] = new Regex("SPU Threads: (?<spu_threads>.*?)\r?$", DefaultOptions),
["delay penalty:"] = new Regex("penalty: (?<spu_delay_penalty>.*?)\r?$", DefaultOptions),
["loop detection:"] = new Regex("detection: (?<spu_loop_detection>.*?)\r?$", DefaultOptions),
["Lib Loader:"] = new Regex("[Ll]oader: (?<lib_loader>.*?)\r?$", DefaultOptions),
["static functions:"] = new Regex("functions: (?<hook_static_functions>.*?)\r?$", DefaultOptions),
["Load libraries:"] = new Regex("libraries: (?<library_list>.*)", DefaultOptions),
},
EndTrigger = "VFS:",
},
new LogSection
{
EndTrigger = "Video:",
},
new LogSection
{
Extractors = new Dictionary<string, Regex>
{
["Renderer:"] = new Regex("Renderer: (?<renderer>.*?)\r?$", DefaultOptions),
["Resolution:"] = new Regex("Resolution: (?<resolution>.*?)\r?$", DefaultOptions),
["Aspect ratio:"] = new Regex("Aspect ratio: (?<aspect_ratio>.*?)\r?$", DefaultOptions),
["Frame limit:"] = new Regex("Frame limit: (?<frame_limit>.*?)\r?$", DefaultOptions),
["Write Color Buffers:"] = new Regex("Write Color Buffers: (?<write_color_buffers>.*?)\r?$", DefaultOptions),
["VSync:"] = new Regex("VSync: (?<vsync>.*?)\r?$", DefaultOptions),
["GPU texture scaling:"] = new Regex("Use GPU texture scaling: (?<gpu_texture_scaling>.*?)\r?$", DefaultOptions),
["Strict Rendering Mode:"] = new Regex("Strict Rendering Mode: (?<strict_rendering_mode>.*?)\r?$", DefaultOptions),
["Vertex Cache:"] = new Regex("Disable Vertex Cache: (?<vertex_cache>.*?)\r?$", DefaultOptions),
["Blit:"] = new Regex("Blit: (?<cpu_blit>.*?)\r?$", DefaultOptions),
["Resolution Scale:"] = new Regex("Resolution Scale: (?<resolution_scale>.*?)\r?$", DefaultOptions),
["Anisotropic Filter"] = new Regex("Anisotropic Filter Override: (?<af_override>.*?)\r?$", DefaultOptions),
["Scalable Dimension:"] = new Regex("Minimum Scalable Dimension: (?<texture_scale_threshold>.*?)\r?$", DefaultOptions),
["12:"] = new Regex(@"(D3D12|DirectX 12):\s*\r?\n\s*Adapter: (?<d3d_gpu>.*?)\r?$", DefaultOptions),
["Vulkan:"] = new Regex(@"Vulkan:\s*\r?\n\s*Adapter: (?<vulkan_gpu>.*?)\r?$", DefaultOptions),
},
EndTrigger = "Audio:",
},
new LogSection
{
EndTrigger = "Log:",
OnSectionEnd = MarkAsComplete,
},
new LogSection
{
Extractors = new Dictionary<string, Regex>
{
["RSX:"] = new Regex(@"RSX:(\d|\.|\s|\w|-)* (?<driver_version>(\d+\.)*\d+)\r?\n[^\n]*?" +
@"RSX: [^\n]+\r?\n[^\n]*?" +
@"RSX: (?<driver_manuf>.*?)\r?\n[^\n]*?" +
@"RSX: Supported texel buffer size", DefaultOptions),
["GL RENDERER:"] = new Regex(@"GL RENDERER: (?<driver_manuf_new>.*?)\r?\n", DefaultOptions),
["GL VERSION:"] = new Regex(@"GL VERSION:(\d|\.|\s|\w|-)* (?<driver_version_new>(\d+\.)*\d+)\r?\n", DefaultOptions),
["texel buffer size reported:"] = new Regex(@"RSX: Supported texel buffer size reported: (?<texel_buffer_size_new>\d*?) bytes", DefaultOptions),
["·F "] = new Regex(@"F \d+:\d+:\d+\.\d+ {.+?} (?<fatal_error>.*?)\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;
}
}
}

View File

@ -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<LogParseState> ReadPipeAsync(PipeReader reader)
{
var currentSectionLines = new LinkedList<ReadOnlySequence<byte>>();
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<byte> line, ReadOnlySequence<byte> buffer, LinkedList<ReadOnlySequence<byte>> 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<byte> buffer, LinkedList<ReadOnlySequence<byte>> 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<byte> buffer, LinkedList<ReadOnlySequence<byte>> 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();
}
}
}

View File

@ -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<string, Action<string, LogParseState>>;
internal partial class LogParser
{
private static readonly ReadOnlyCollection<LogSectionParser> SectionParsers;
private static readonly Encoding Utf8 = new UTF8Encoding(false);
static LogParser()
{
var parsers = new List<LogSectionParser>(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<Action<string, LogParseState>>(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<string, LogParseState, Task> OnLineCheckAsync;
public Action<LogParseState> OnSectionEnd;
public string EndTrigger;
}
}
}

View File

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

View File

@ -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<string, Regex> Extractors;
public Func<string, LogParseState, Task> OnNewLineAsync;
public Action<LogParseState> OnSectionEnd;
}
}

View File

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

View File

@ -0,0 +1,12 @@
using System.IO.Pipelines;
using System.Threading.Tasks;
using DSharpPlus.Entities;
namespace CompatBot.LogParsing.SourceHandlers
{
internal interface ISourceHandler
{
Task<bool> CanHandleAsync(DiscordAttachment attachment);
Task FillPipeAsync(DiscordAttachment attachment, PipeWriter writer);
}
}

View File

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

View File

@ -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<byte> bufferPool = ArrayPool<byte>.Create(1024, 16);
public async Task<bool> 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<byte>(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();
}
}
}

97
CompatBot/Program.cs Normal file
View File

@ -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<Misc>();
commands.RegisterCommands<CompatList>();
commands.RegisterCommands<Sudo>();
commands.RegisterCommands<Antipiracy>();
commands.RegisterCommands<Warnings>();
commands.RegisterCommands<Explain>();
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");
}
}
}

View File

@ -0,0 +1,15 @@
{
"profiles": {
"default": {
"commandName": "Project",
"environmentVariables": {
"BotLogId": "",
"BotSpamId": "",
"BotAdminId": "",
"BotChannelId": "",
"BotRulesChannelId": "",
"Token": ""
}
}
}
}

View File

@ -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<ulong, Moderator> mods;
public static ReadOnlyDictionary<ulong, Moderator> Mods => new ReadOnlyDictionary<ulong, Moderator>(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<bool> 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<bool> 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<bool> 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<bool> 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;
}
}
}

View File

@ -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<string> PiracyStrings;
private static AhoCorasickDoubleArrayTrie<string> matcher;
static PiracyStringProvider()
{
PiracyStrings = BotDb.Instance.Piracystring.Select(ps => ps.String).ToList();
RebuildMatcher();
}
public static async Task<bool> 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<bool> 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<string> 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<string>(PiracyStrings.ToDictionary(s => s, s => s));
}
}
}

View File

@ -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(?<version>(\d|\.)+)(-(?<build>\d+))?-(?<commit>[0-9a-f]+) (?<stage>\w+) \| (?<branch>.*?)\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(?<version>(\d|\.)+)(-(?<build>\d+))?-(?<commit>[0-9a-f]+)_",
RegexOptions.Compiled | RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase | RegexOptions.Singleline);
public static async Task<DiscordEmbed> 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 <https://rpcs3.net/quickstart>";
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<UpdateInfo> 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;
}
}
}

View File

@ -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<string, DiscordColor> StatusColors = new Dictionary<string, DiscordColor>(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<string, TitleInfo> resultInfo)
{
return resultInfo.Value.AsString(resultInfo.Key);
}
public static DiscordEmbed AsEmbed(this KeyValuePair<string, TitleInfo> resultInfo)
{
return resultInfo.Value.AsEmbed(resultInfo.Key);
}
}
}

View File

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

View File

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

View File

@ -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<DiscordChannel> CreateDmAsync(this CommandContext ctx)
{
return ctx.Channel.IsPrivate ? ctx.Channel : await ctx.Member.CreateDmChannelAsync().ConfigureAwait(false);
}
public static Task<string> 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<string> 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<IReadOnlyCollection<DiscordMessage>> 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<DiscordMessage> 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<DiscordMessage> ReportAsync(this DiscordClient client, string infraction, DiscordMessage message, IEnumerable<DiscordUser> 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;
}
}
}

View File

@ -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<DiscordEmbed> BreakInEmbeds(DiscordEmbedBuilder builder, IEnumerable<string> 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<string> 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;
}
}
}

View File

@ -0,0 +1,21 @@
using System.IO;
using System.Threading.Tasks;
namespace CompatBot.Utils
{
internal static class StreamExtensions
{
public static async Task<int> 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;
}
}
}

View File

@ -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<byte> buffer, Encoding encoding = null)
{
encoding = encoding ?? Encoding.ASCII;
if (buffer.IsSingleSegment)
return encoding.GetString(buffer.First.Span);
void Splice(Span<char> span, ReadOnlySequence<byte> sequence)
{
foreach (var segment in sequence)
{
encoding.GetChars(segment.Span, span);
span = span.Slice(segment.Length);
}
}
return string.Create((int)buffer.Length, buffer, Splice);
}
}
}

23
LICENSE
View File

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

View File

@ -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`
How to Run in Production
------------------------
* Change configuration if needed (probably just token)
* Put `bot.db` in `CompatBot/`
* `$ cd CompatBot`
* `$ dotnet run -c Release [token]`

View File

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

View File

@ -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 = "<newline>"
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")
}

View File

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

View File

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

View File

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

View File

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

1070
bot.py

File diff suppressed because it is too large Load Diff

View File

@ -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 = [
]

View File

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

View File

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

31
discord-bot-net.sln Normal file
View File

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

View File

@ -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<id>[A-z]{4}\\d{5})')
LIBRARIES_PATTERN = re.compile('Load libraries:(?P<libraries>.*)', 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<build_and_specs>.*)', flags=re.DOTALL | re.MULTILINE),
'function': clear_results
},
{
'end_trigger': 'Core:',
'regex': re.compile('Path: (?:(?P<win_path>\\w:/)|(?P<lin_path>/)).*?\n'
'(?:.* custom config: (?P<custom_config>.*?)\n.*?)?',
flags=re.DOTALL | re.MULTILINE),
'function': [get_id, piracy_check]
},
{
'end_trigger': 'VFS:',
'regex': re.compile('Decoder: (?P<ppu_decoder>.*?)\n.*?'
'Threads: (?P<ppu_threads>.*?)\n.*?'
'(?:scheduler: (?P<thread_scheduler>.*?)\n.*?)?'
'Decoder: (?P<spu_decoder>.*?)\n.*?'
'(?:secondary cores: (?P<spu_secondary_cores>.*?)\n.*?)?'
'priority: (?P<spu_lower_thread_priority>.*?)\n.*?'
'SPU Threads: (?P<spu_threads>.*?)\n.*?'
'penalty: (?P<spu_delay_penalty>.*?)\n.*?'
'detection: (?P<spu_loop_detection>.*?)\n.*?'
'[Ll]oader: (?P<lib_loader>.*?)\n.*?'
'functions: (?P<hook_static_functions>.*?)\n.*',
flags=re.DOTALL | re.MULTILINE),
'function': get_libraries
},
{
'end_trigger': 'Video:',
'regex': None,
'function': None
},
{
'end_trigger': 'Audio:',
'regex': re.compile('Renderer: (?P<renderer>.*?)\n.*?'
'Resolution: (?P<resolution>.*?)\n.*?'
'Aspect ratio: (?P<aspect_ratio>.*?)\n.*?'
'Frame limit: (?P<frame_limit>.*?)\n.*?'
'Write Color Buffers: (?P<write_color_buffers>.*?)\n.*?'
'VSync: (?P<vsync>.*?)\n.*?'
'Use GPU texture scaling: (?P<gpu_texture_scaling>.*?)\n.*?'
'Strict Rendering Mode: (?P<strict_rendering_mode>.*?)\n.*?'
'Disable Vertex Cache: (?P<vertex_cache>.*?)\n.*?'
'Blit: (?P<cpu_blit>.*?)\n.*?'
'Resolution Scale: (?P<resolution_scale>.*?)\n.*?'
'Anisotropic Filter Override: (?P<af_override>.*?)\n.*?'
'Minimum Scalable Dimension: (?P<texture_scale_threshold>.*?)\n.*?'
'(?:D3D12|DirectX 12):\\s*\n\\s*Adapter: (?P<d3d_gpu>.*?)\n.*?'
'Vulkan:\\s*\n\\s*Adapter: (?P<vulkan_gpu>.*?)\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<driver_version>(?:\\d+\\.)*\\d+)\n[^\n]*?'
'RSX: [^\n]+\n[^\n]*?'
'RSX: (?P<driver_manuf>.*?)\n[^\n]*?'
'RSX: Supported texel buffer size reported: (?P<texel_buffer_size>\\d*?) bytes'
')|(?:'
'GL RENDERER: (?P<driver_manuf_new>.*?)\n[^\n]*?'
'GL VERSION:(?:\\d|\\.|\\s|\\w|-)* (?P<driver_version_new>(?:\\d+\\.)*\\d+)\n[^\n]*?'
'RSX: [^\n]+\n[^\n]*?'
'RSX: Supported texel buffer size reported: (?P<texel_buffer_size_new>\\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

View File

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

View File

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

6
nuget.config Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="DSharpPlus Nightlies" value="https://www.myget.org/F/dsharpplus-nightly/api/v3/index.json" />
</packageSources>
</configuration>

View File

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