feat: add machine learning mode

This commit is contained in:
Anna 2020-12-20 21:49:10 -05:00
parent bbfa04f4d8
commit 76462ff628
26 changed files with 1141 additions and 369 deletions

View File

@ -0,0 +1,35 @@
using System;
using System.IO;
using System.Threading.Channels;
using Microsoft.ML;
namespace NoSoliciting.Classifier {
public class Classifier : IDisposable {
private string ConfigPath { get; }
private MLContext Context { get; }
private ITransformer Model { get; }
private DataViewSchema Schema { get; }
private PredictionEngine<MessageData, MessagePrediction> PredictionEngine { get; }
public Classifier(string configPath) {
this.ConfigPath = configPath;
this.Context = new MLContext();
this.Model = this.Context.Model.Load(Path.Combine(this.ConfigPath, "model.zip"), out var schema);
this.Schema = schema;
this.PredictionEngine = this.Context.Model.CreatePredictionEngine<MessageData, MessagePrediction>(this.Model, this.Schema);
}
public string Classify(ushort channel, string message) {
var data = new MessageData(channel, message);
var pred = this.PredictionEngine.Predict(data);
return pred.Category;
}
public void Dispose() {
this.PredictionEngine.Dispose();
}
}
}

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<Costura>
<ExcludeAssemblies>
Costura
</ExcludeAssemblies>
<Unmanaged64Assemblies>
CpuMathNative
LdaNative
</Unmanaged64Assemblies>
<PreloadOrder>
CpuMathNative
LdaNative
</PreloadOrder>
</Costura>
</Weavers>

View File

@ -0,0 +1,24 @@
using Microsoft.ML.Data;
namespace NoSoliciting.Classifier {
public class MessageData {
public string? Category { get; }
public uint Channel { get; }
public string Message { get; }
public MessageData(uint channel, string message) {
this.Channel = channel;
this.Message = message;
}
}
public class MessagePrediction {
[ColumnName("PredictedLabel")]
public string Category { get; set; } = null!;
[ColumnName("Score")]
public float[] Probabilities { get; set; } = null!;
}
}

View File

@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>8</LangVersion>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Costura.Fody" Version="4.1.0">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Fody" Version="6.3.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.ML" Version="1.5.4" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<runtime>
<assemblyBinding>
<dependentAssembly>
<assemblyIdentity name="System.Numerics.Vectors"
culture="neutral"
publicKeyToken="b03f5f7f11d50a3a" />
<bindingRedirect oldVersion="4.1.3.0"
newVersion="4.1.4.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>

View File

@ -9,6 +9,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NoSoliciting.Tests", "NoSoliciting.Tests\NoSoliciting.Tests.csproj", "{1962D91F-543A-4214-88FD-788BB7ACECE3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NoSoliciting.Classifier", "NoSoliciting.Classifier\NoSoliciting.Classifier.csproj", "{F5F1671E-8DC4-47E4-AD3E-55DA5BDF1B93}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -23,6 +25,10 @@ Global
{1962D91F-543A-4214-88FD-788BB7ACECE3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1962D91F-543A-4214-88FD-788BB7ACECE3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1962D91F-543A-4214-88FD-788BB7ACECE3}.Release|Any CPU.Build.0 = Release|Any CPU
{F5F1671E-8DC4-47E4-AD3E-55DA5BDF1B93}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F5F1671E-8DC4-47E4-AD3E-55DA5BDF1B93}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F5F1671E-8DC4-47E4-AD3E-55DA5BDF1B93}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F5F1671E-8DC4-47E4-AD3E-55DA5BDF1B93}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@ -1,7 +1,4 @@
using Dalamud.Game.Chat;
using Dalamud.Plugin;
using NoSoliciting.Properties;
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@ -9,6 +6,9 @@ using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Dalamud.Game.Chat;
using Dalamud.Plugin;
using NoSoliciting.Properties;
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
using YamlDotNet.Serialization;
@ -16,15 +16,14 @@ using YamlDotNet.Serialization.NamingConventions;
namespace NoSoliciting {
public class Definitions {
public static string LastError { get; private set; } = null;
public static DateTime? LastUpdate { get; set; } = null;
public static string? LastError { get; private set; }
public static DateTime? LastUpdate { get; set; }
private const string URL = "https://git.sr.ht/~jkcclemens/NoSoliciting/blob/master/NoSoliciting/definitions.yaml";
private const string Url = "https://git.sr.ht/~jkcclemens/NoSoliciting/blob/master/NoSoliciting/definitions.yaml";
public uint Version { get; private set; }
public Uri ReportUrl { get; private set; }
[YamlIgnore]
public int Count { get => this.Chat.Count + this.PartyFinder.Count + this.Global.Count; }
public Dictionary<string, Definition> Chat { get; private set; }
public Dictionary<string, Definition> PartyFinder { get; private set; }
public Dictionary<string, Definition> Global { get; private set; }
@ -34,7 +33,7 @@ namespace NoSoliciting {
return LoadDefaults();
#endif
Definitions defs = null;
Definitions? defs = null;
var download = await Download().ConfigureAwait(true);
if (download != null) {
@ -43,7 +42,7 @@ namespace NoSoliciting {
try {
UpdateCache(plugin, download.Item2);
} catch (IOException e) {
PluginLog.Log($"Could not update cache.");
PluginLog.Log("Could not update cache.");
PluginLog.Log(e.ToString());
}
}
@ -60,21 +59,12 @@ namespace NoSoliciting {
return de.Deserialize<Definitions>(text);
}
private static string PluginFolder(Plugin plugin) {
return Path.Combine(new string[] {
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"XIVLauncher",
"pluginConfigs",
plugin.Name,
});
}
private static async Task<Definitions> CacheOrDefault(Plugin plugin) {
if (plugin == null) {
throw new ArgumentNullException(nameof(plugin), "Plugin cannot be null");
}
var pluginFolder = PluginFolder(plugin);
var pluginFolder = Util.PluginFolder(plugin);
var cachedPath = Path.Combine(pluginFolder, "definitions.yaml");
if (!File.Exists(cachedPath)) {
@ -97,25 +87,25 @@ namespace NoSoliciting {
}
private static Definitions LoadDefaults() {
return Load(Resources.default_definitions);
return Load(Resources.DefaultDefinitions);
}
private static async Task<Tuple<Definitions, string>> Download() {
private static async Task<Tuple<Definitions, string>?> Download() {
try {
using var client = new WebClient();
var text = await client.DownloadStringTaskAsync(URL).ConfigureAwait(true);
var text = await client.DownloadStringTaskAsync(Url).ConfigureAwait(true);
LastError = null;
return Tuple.Create(Load(text), text);
} catch (Exception e) when (e is WebException || e is YamlException) {
PluginLog.Log($"Could not download newest definitions.");
PluginLog.Log("Could not download newest definitions.");
PluginLog.Log(e.ToString());
LastError = e.Message;
return null;
}
}
private static async void UpdateCache(Plugin plugin, string defs) {
var pluginFolder = PluginFolder(plugin);
private static async void UpdateCache(IDalamudPlugin plugin, string defs) {
var pluginFolder = Util.PluginFolder(plugin);
Directory.CreateDirectory(pluginFolder);
var cachePath = Path.Combine(pluginFolder, "definitions.yaml");
@ -148,6 +138,7 @@ namespace NoSoliciting {
if (!plugin.Config.FilterStatus.TryGetValue(chat.Id, out _)) {
plugin.Config.FilterStatus[chat.Id] = chat.Default;
}
if (!plugin.Config.FilterStatus.TryGetValue(pf.Id, out _)) {
plugin.Config.FilterStatus[pf.Id] = pf.Default;
}
@ -158,25 +149,26 @@ namespace NoSoliciting {
}
public class Definition {
private bool initialised = false;
private bool _initialised;
[YamlIgnore]
public string Id { get; private set; }
public List<List<Matcher>> RequiredMatchers { get; private set; } = new List<List<Matcher>>();
public List<List<Matcher>> LikelyMatchers { get; private set; } = new List<List<Matcher>>();
public int LikelihoodThreshold { get; private set; } = 0;
public bool IgnoreCase { get; private set; } = false;
public int LikelihoodThreshold { get; private set; }
public bool IgnoreCase { get; private set; }
public bool Normalise { get; private set; } = true;
public List<XivChatType> Channels { get; private set; } = new List<XivChatType>();
public OptionNames Option { get; private set; }
public bool Default { get; private set; } = false;
public bool Default { get; private set; }
public void Initialise(string id) {
if (this.initialised) {
if (this._initialised) {
return;
}
this.initialised = true;
this._initialised = true;
this.Id = id ?? throw new ArgumentNullException(nameof(id), "string cannot be null");
@ -217,13 +209,7 @@ namespace NoSoliciting {
}
// calculate likelihood
var likelihood = 0;
foreach (var matchers in this.LikelyMatchers) {
if (matchers.Any(matcher => matcher.Matches(text))) {
likelihood += 1;
}
}
var likelihood = this.LikelyMatchers.Count(matchers => matchers.Any(matcher => matcher.Matches(text)));
// matches only if likelihood is greater than or equal the threshold
return likelihood >= this.LikelihoodThreshold;
@ -244,8 +230,8 @@ namespace NoSoliciting {
}
public class Matcher {
private string substring;
private Regex regex;
private string? substring;
private Regex? regex;
public Matcher(string substring) {
this.substring = substring ?? throw new ArgumentNullException(nameof(substring), "string cannot be null");
@ -261,7 +247,7 @@ namespace NoSoliciting {
}
if (this.regex != null) {
this.regex = new Regex(this.regex.ToString(), regex.Options | RegexOptions.IgnoreCase);
this.regex = new Regex(this.regex.ToString(), this.regex.Options | RegexOptions.IgnoreCase);
}
}
@ -271,7 +257,7 @@ namespace NoSoliciting {
}
if (this.substring != null) {
return text.Contains(substring);
return text.Contains(this.substring);
}
if (this.regex != null) {

View File

@ -9,7 +9,7 @@ namespace NoSoliciting {
throw new ArgumentNullException(nameof(config), "PluginConfiguration cannot be null");
}
if (!config.AdvancedMode || !config.CustomChatFilter) {
if (!config.CustomChatFilter) {
return false;
}

View File

@ -9,7 +9,7 @@ namespace NoSoliciting {
throw new ArgumentNullException(nameof(config), "PluginConfiguration cannot be null");
}
if (!config.AdvancedMode || !config.CustomPFFilter) {
if (!config.CustomPFFilter) {
return false;
}

View File

@ -4,49 +4,196 @@ using Dalamud.Hooking;
using Dalamud.Plugin;
using System;
using System.Runtime.InteropServices;
using NoSoliciting.Ml;
namespace NoSoliciting {
public partial class Filter : IDisposable {
private readonly Plugin plugin;
private bool clearOnNext = false;
private const uint MinWords = 4;
private delegate void HandlePFPacketDelegate(IntPtr param_1, IntPtr param_2);
private readonly Hook<HandlePFPacketDelegate> handlePacketHook;
public static readonly ChatType[] FilteredChatTypes = {
ChatType.Say,
ChatType.Yell,
ChatType.Shout,
ChatType.TellIncoming,
ChatType.Party,
ChatType.CrossParty,
ChatType.Alliance,
ChatType.FreeCompany,
ChatType.PvpTeam,
ChatType.CrossLinkshell1,
ChatType.CrossLinkshell2,
ChatType.CrossLinkshell3,
ChatType.CrossLinkshell4,
ChatType.CrossLinkshell5,
ChatType.CrossLinkshell6,
ChatType.CrossLinkshell7,
ChatType.CrossLinkshell8,
ChatType.Linkshell1,
ChatType.Linkshell2,
ChatType.Linkshell3,
ChatType.Linkshell4,
ChatType.Linkshell5,
ChatType.Linkshell6,
ChatType.Linkshell7,
ChatType.Linkshell8,
ChatType.NoviceNetwork,
};
private delegate long HandlePFSummary2Delegate(long param_1, long param_2, byte param_3);
private readonly Hook<HandlePFSummary2Delegate> handleSummaryHook;
private Plugin Plugin { get; }
private bool _clearOnNext;
private bool disposedValue;
private delegate void HandlePfPacketDelegate(IntPtr param1, IntPtr param2);
private readonly Hook<HandlePfPacketDelegate>? _handlePacketHook;
private delegate IntPtr HandlePfSummaryDelegate(IntPtr param1, IntPtr param2, byte param3);
private readonly Hook<HandlePfSummaryDelegate>? _handleSummaryHook;
private bool _disposedValue;
public Filter(Plugin plugin) {
this.plugin = plugin ?? throw new ArgumentNullException(nameof(plugin), "Plugin cannot be null");
this.Plugin = plugin ?? throw new ArgumentNullException(nameof(plugin), "Plugin cannot be null");
var listingPtr = this.plugin.Interface.TargetModuleScanner.ScanText("40 53 41 57 48 83 EC 28 48 8B D9");
var summaryPtr = this.plugin.Interface.TargetModuleScanner.ScanText("48 89 5C 24 ?? 48 89 74 24 ?? 57 48 83 EC 20 48 8B FA 48 8B F1 45 84 C0 74 ?? 0F B7 0A");
if (listingPtr == IntPtr.Zero || summaryPtr == IntPtr.Zero) {
PluginLog.Log("Party Finder filtering disabled because hook could not be created.");
var listingPtr = this.Plugin.Interface.TargetModuleScanner.ScanText("40 53 41 57 48 83 EC 28 48 8B D9");
var summaryPtr = this.Plugin.Interface.TargetModuleScanner.ScanText("48 89 5C 24 ?? 48 89 74 24 ?? 57 48 83 EC 20 48 8B FA 48 8B F1 45 84 C0 74 ?? 0F B7 0A");
this._handlePacketHook = new Hook<HandlePfPacketDelegate>(listingPtr, new HandlePfPacketDelegate(this.TransformPfPacket));
this._handlePacketHook.Enable();
this._handleSummaryHook = new Hook<HandlePfSummaryDelegate>(summaryPtr, new HandlePfSummaryDelegate(this.HandleSummary));
this._handleSummaryHook.Enable();
}
public void OnChat(XivChatType type, uint senderId, ref SeString sender, ref SeString message, ref bool isHandled) {
isHandled = isHandled || this.FilterMessage(type, sender, message);
}
private void TransformPfPacket(IntPtr param1, IntPtr data) {
if (data == IntPtr.Zero) {
goto Return;
}
if (this.Plugin.MlReady) {
this.MlTransformPfPacket(data);
} else if (this.Plugin.DefsReady) {
this.DefsTransformPfPacket(data);
}
Return:
this._handlePacketHook!.Original(param1, data);
}
private bool FilterMessage(XivChatType type, SeString sender, SeString message) {
if (message == null) {
throw new ArgumentNullException(nameof(message), "SeString cannot be null");
}
if (this.Plugin.MlReady) {
return this.MlFilterMessage(type, sender, message);
}
return this.Plugin.DefsReady && this.DefsFilterMessage(type, sender, message);
}
private bool MlFilterMessage(XivChatType type, SeString sender, SeString message) {
if (this.Plugin.MlFilter == null) {
return false;
}
var chatType = ChatTypeExt.FromDalamud(type);
// NOTE: don't filter on user-controlled chat types here because custom filters are supposed to check all
// messages except battle messages
if (chatType.IsBattle()) {
return false;
}
var text = message.TextValue;
// only look at messages >= min words
if (text.Split(' ').Length < MinWords) {
return false;
}
string? reason = null;
// step 1. classify the message using the model
var category = this.Plugin.MlFilter.ClassifyMessage((ushort) chatType, text);
// step 1a. only filter if configured to act on this channel
var filter = category != MessageCategory.Normal
&& this.Plugin.Config.MlEnabledOn(category, chatType)
&& SetReason(out reason, category.Name());
// step 2. check for custom filters if enabled
filter = filter || this.Plugin.Config.CustomChatFilter
&& Chat.MatchesCustomFilters(text, this.Plugin.Config)
&& SetReason(out reason, "custom");
this.Plugin.AddMessageHistory(new Message(
this.Plugin.MlFilter.Version,
ChatTypeExt.FromDalamud(type),
sender,
message,
reason
));
if (filter && this.Plugin.Config.LogFilteredChat) {
PluginLog.Log($"Filtered chat message ({reason}): {text}");
}
return filter;
}
private bool DefsFilterMessage(XivChatType type, SeString sender, SeString message) {
if (this.Plugin.Definitions == null || ChatTypeExt.FromDalamud(type).IsBattle()) {
return false;
}
var text = message.TextValue;
string? reason = null;
var filter = false;
foreach (var def in this.Plugin.Definitions.Chat.Values) {
filter = filter || this.Plugin.Config.FilterStatus.TryGetValue(def.Id, out var enabled)
&& enabled
&& def.Matches(type, text)
&& SetReason(out reason, def.Id);
}
// check for custom filters if enabled
filter = filter || this.Plugin.Config.CustomChatFilter
&& Chat.MatchesCustomFilters(text, this.Plugin.Config)
&& SetReason(out reason, "custom");
this.Plugin.AddMessageHistory(new Message(
this.Plugin.Definitions.Version,
ChatTypeExt.FromDalamud(type),
sender,
message,
reason
));
if (filter && this.Plugin.Config.LogFilteredChat) {
PluginLog.Log($"Filtered chat message ({reason}): {text}");
}
return filter;
}
private void MlTransformPfPacket(IntPtr data) {
if (this.Plugin.MlFilter == null) {
return;
}
this.handlePacketHook = new Hook<HandlePFPacketDelegate>(listingPtr, new HandlePFPacketDelegate(this.HandlePFPacket));
this.handlePacketHook.Enable();
this.handleSummaryHook = new Hook<HandlePFSummary2Delegate>(summaryPtr, new HandlePFSummary2Delegate(this.HandleSummary));
this.handleSummaryHook.Enable();
if (this._clearOnNext) {
this.Plugin.ClearPartyFinderHistory();
this._clearOnNext = false;
}
private void HandlePFPacket(IntPtr param_1, IntPtr param_2) {
if (this.plugin.Definitions == null) {
this.handlePacketHook.Original(param_1, param_2);
return;
}
if (this.clearOnNext) {
this.plugin.ClearPartyFinderHistory();
this.clearOnNext = false;
}
var dataPtr = param_2 + 0x10;
var dataPtr = data + 0x10;
// parse the packet into a struct
var packet = Marshal.PtrToStructure<PfPacket>(dataPtr);
@ -61,31 +208,39 @@ namespace NoSoliciting {
var desc = listing.Description();
string reason = null;
var filter = false;
filter = filter || (this.plugin.Config.FilterHugeItemLevelPFs
&& listing.minimumItemLevel > FilterUtil.MaxItemLevelAttainable(this.plugin.Interface.Data)
&& SetReason(ref reason, "ilvl"));
foreach (var def in this.plugin.Definitions.PartyFinder.Values) {
filter = filter || (this.plugin.Config.FilterStatus.TryGetValue(def.Id, out var enabled)
&& enabled
&& def.Matches(XivChatType.None, desc)
&& SetReason(ref reason, def.Id));
// only look at pfs >= min words
if (desc.Split(' ').Length < MinWords) {
continue;
}
// check for custom filters if enabled
filter = filter || (this.plugin.Config.CustomPFFilter
&& PartyFinder.MatchesCustomFilters(desc, this.plugin.Config)
&& SetReason(ref reason, "custom"));
string? reason = null;
this.plugin.AddPartyFinderHistory(new Message(
defs: this.plugin.Definitions,
type: ChatType.None,
sender: listing.Name(),
content: listing.Description(),
reason: reason
// step 1. check if pf has an item level that's too high
var filter = this.Plugin.Config.FilterHugeItemLevelPFs
&& listing.minimumItemLevel > FilterUtil.MaxItemLevelAttainable(this.Plugin.Interface.Data)
&& SetReason(out reason, "ilvl");
// step 2. check custom filters
filter = filter || this.Plugin.Config.CustomPFFilter
&& PartyFinder.MatchesCustomFilters(desc, this.Plugin.Config)
&& SetReason(out reason, "custom");
if (!filter) {
// step 3. check the model's prediction
var category = this.Plugin.MlFilter.ClassifyMessage((ushort) ChatType.None, desc);
// step 3a. filter the message if configured to do so
filter = category != MessageCategory.Normal
&& this.Plugin.Config.MlEnabledOn(category, ChatType.None)
&& SetReason(out reason, category.Name());
}
this.Plugin.AddPartyFinderHistory(new Message(
this.Plugin.MlFilter.Version,
ChatType.None,
listing.Name(),
listing.Description(),
reason
));
if (!filter) {
@ -95,8 +250,10 @@ namespace NoSoliciting {
// replace the listing with an empty one
packet.listings[i] = new PfListing();
if (this.Plugin.Config.LogFilteredPfs) {
PluginLog.Log($"Filtered PF listing from {listing.Name()} ({reason}): {listing.Description()}");
}
}
// get some memory for writing to
var newPacket = new byte[PacketInfo.PacketSize];
@ -111,79 +268,114 @@ namespace NoSoliciting {
// free memory
pinnedArray.Free();
// call original function
this.handlePacketHook.Original(param_1, param_2);
}
private long HandleSummary(long param_1, long param_2, byte param_3) {
this.clearOnNext = true;
return this.handleSummaryHook.Original(param_1, param_2, param_3);
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "fulfilling a delegate")]
public void OnChat(XivChatType type, uint senderId, ref SeString sender, ref SeString message, ref bool isHandled) {
if (message == null) {
throw new ArgumentNullException(nameof(message), "SeString cannot be null");
}
if (this.plugin.Definitions == null || ChatTypeExt.FromDalamud(type).IsBattle()) {
private void DefsTransformPfPacket(IntPtr data) {
if (this.Plugin.Definitions == null) {
return;
}
var text = message.TextValue;
if (this._clearOnNext) {
this.Plugin.ClearPartyFinderHistory();
this._clearOnNext = false;
}
string reason = null;
var dataPtr = data + 0x10;
// parse the packet into a struct
var packet = Marshal.PtrToStructure<PfPacket>(dataPtr);
for (var i = 0; i < packet.listings.Length; i++) {
var listing = packet.listings[i];
// only look at listings that aren't null
if (listing.IsNull()) {
continue;
}
var desc = listing.Description();
string? reason = null;
var filter = false;
foreach (var def in this.plugin.Definitions.Chat.Values) {
filter = filter || (this.plugin.Config.FilterStatus.TryGetValue(def.Id, out var enabled)
filter = filter || this.Plugin.Config.FilterHugeItemLevelPFs
&& listing.minimumItemLevel > FilterUtil.MaxItemLevelAttainable(this.Plugin.Interface.Data)
&& SetReason(out reason, "ilvl");
foreach (var def in this.Plugin.Definitions.PartyFinder.Values) {
filter = filter || this.Plugin.Config.FilterStatus.TryGetValue(def.Id, out var enabled)
&& enabled
&& def.Matches(type, text)
&& SetReason(ref reason, def.Id));
&& def.Matches(XivChatType.None, desc)
&& SetReason(out reason, def.Id);
}
// check for custom filters if enabled
filter = filter || (this.plugin.Config.CustomChatFilter
&& Chat.MatchesCustomFilters(text, this.plugin.Config)
&& SetReason(ref reason, "custom"));
filter = filter || this.Plugin.Config.CustomPFFilter
&& PartyFinder.MatchesCustomFilters(desc, this.Plugin.Config)
&& SetReason(out reason, "custom");
this.plugin.AddMessageHistory(new Message(
defs: this.plugin.Definitions,
type: ChatTypeExt.FromDalamud(type),
sender: sender,
content: message,
reason: reason
this.Plugin.AddPartyFinderHistory(new Message(
this.Plugin.Definitions.Version,
ChatType.None,
listing.Name(),
listing.Description(),
reason
));
if (!filter) {
return;
continue;
}
PluginLog.Log($"Filtered chat message ({reason}): {text}");
isHandled = true;
// replace the listing with an empty one
packet.listings[i] = new PfListing();
if (this.Plugin.Config.LogFilteredPfs) {
PluginLog.Log($"Filtered PF listing from {listing.Name()} ({reason}): {listing.Description()}");
}
}
private static bool SetReason(ref string reason, string value) {
// get some memory for writing to
var newPacket = new byte[PacketInfo.PacketSize];
var pinnedArray = GCHandle.Alloc(newPacket, GCHandleType.Pinned);
var pointer = pinnedArray.AddrOfPinnedObject();
// write our struct into the memory (doing this directly crashes the game)
Marshal.StructureToPtr(packet, pointer, false);
// copy our new memory over the game's
Marshal.Copy(newPacket, 0, dataPtr, PacketInfo.PacketSize);
// free memory
pinnedArray.Free();
}
private IntPtr HandleSummary(IntPtr param1, IntPtr param2, byte param3) {
this._clearOnNext = true;
return this._handleSummaryHook!.Original(param1, param2, param3);
}
private static bool SetReason(out string reason, string value) {
reason = value;
return true;
}
protected virtual void Dispose(bool disposing) {
if (!disposedValue) {
if (disposing) {
this.handlePacketHook?.Dispose();
this.handleSummaryHook?.Dispose();
private void Dispose(bool disposing) {
if (this._disposedValue) {
return;
}
disposedValue = true;
if (disposing) {
this._handlePacketHook?.Dispose();
this._handleSummaryHook?.Dispose();
}
this._disposedValue = true;
}
public void Dispose() {
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
this.Dispose(true);
GC.SuppressFinalize(this);
}
}

View File

@ -1,16 +0,0 @@
// This file is used by Code Analysis to maintain SuppressMessage
// attributes that are applied to this project.
// Project-level suppressions either have no target or are given
// a specific target and scoped to a namespace, type, member, etc.
using System.Diagnostics.CodeAnalysis;
[assembly: SuppressMessage("Performance", "CA1815:Override equals and operator equals on value types", Scope = "type", Target = "~T:NoSoliciting.PFPacket")]
[assembly: SuppressMessage("Performance", "CA1815:Override equals and operator equals on value types", Scope = "type", Target = "~T:NoSoliciting.PFListing")]
[assembly: SuppressMessage("Design", "CA1051:Do not declare visible instance fields", Scope = "member", Target = "~F:NoSoliciting.PFPacket.padding1")]
[assembly: SuppressMessage("Design", "CA1051:Do not declare visible instance fields", Scope = "member", Target = "~F:NoSoliciting.PFPacket.listings")]
[assembly: SuppressMessage("Design", "CA1051:Do not declare visible instance fields", Scope = "member", Target = "~F:NoSoliciting.PFListing.header")]
[assembly: SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Scope = "module")]
[assembly: SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "no", Scope = "module")]
[assembly: SuppressMessage("Design", "CA1034:Nested types should not be visible", Scope = "module")]
[assembly: SuppressMessage("Design", "CA1724", Scope = "module")]

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8" ?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Target Name="ILRepacker" AfterTargets="Build" Condition="'$(Configuration)' == 'RELEASE'">
<ItemGroup>
<!-- Include="$(OutputPath)\NoSoliciting.dll;$(OutputPath)\System.*.dll;$(OutputPath)\Microsoft.*.dll;$(OutputPath)\YamlDotNet.dll;$(OutputPath)\Newtonsoft.Json.dll" -->
<InputAssemblies
Include="$(OutputPath)\NoSoliciting.dll;$(OutputPath)\NoSoliciting.Classifier.dll;$(OutputPath)\YamlDotNet.dll;$(OutputPath)\Newtonsoft.Json.dll"
Exclude="$(OutputPath)\*Native.dll"/>
</ItemGroup>
<ILRepack
Parallel="true"
Internalize="true"
InputAssemblies="@(InputAssemblies)"
TargetKind="Dll"
TargetPlatformVersion="v4"
LibraryPath="$(OutputPath)"
OutputFile="$(OutputPath)\$(AssemblyName).dll"/>
</Target>
</Project>

View File

@ -9,21 +9,17 @@ using System.Linq;
namespace NoSoliciting {
public class Message {
public Guid Id { get; private set; }
public uint DefinitionsVersion { get; private set; }
public DateTime Timestamp { get; private set; }
public ChatType ChatType { get; private set; }
public SeString Sender { get; private set; }
public SeString Content { get; private set; }
public string FilterReason { get; private set; }
public Message(Definitions defs, ChatType type, SeString sender, SeString content, string reason) {
if (defs == null) {
throw new ArgumentNullException(nameof(defs), "Definitions cannot be null");
}
public Guid Id { get; }
public uint DefinitionsVersion { get; }
public DateTime Timestamp { get; }
public ChatType ChatType { get; }
public SeString Sender { get; }
public SeString Content { get; }
public string? FilterReason { get; }
public Message(uint defsVersion, ChatType type, SeString sender, SeString content, string? reason) {
this.Id = Guid.NewGuid();
this.DefinitionsVersion = defs.Version;
this.DefinitionsVersion = defsVersion;
this.Timestamp = DateTime.Now;
this.ChatType = type;
this.Sender = sender;
@ -31,8 +27,8 @@ namespace NoSoliciting {
this.FilterReason = reason;
}
public Message(Definitions defs, ChatType type, string sender, string content, string reason) : this(
defs,
public Message(uint defsVersion, ChatType type, string sender, string content, string? reason) : this(
defsVersion,
type,
new SeString(new Payload[] { new TextPayload(sender) }),
new SeString(new Payload[] { new TextPayload(content) }),
@ -50,7 +46,7 @@ namespace NoSoliciting {
// and I don't want to write a custom converter to overwrite their stupiditiy
public List<byte> Sender { get; set; }
public List<byte> Content { get; set; }
public string Reason { get; set; }
public string? Reason { get; set; }
}
public string ToJson() {

View File

@ -1,5 +1,9 @@
namespace NoSoliciting.Ml {
using System;
namespace NoSoliciting.Ml {
public class Manifest {
public uint Version { get; set; }
public Uri ModelUrl { get; set; }
public Uri ReportUrl { get; set; }
}
}

View File

@ -1,5 +1,156 @@
namespace NoSoliciting.Ml {
public class MlFilter {
using System;
using System.IO;
using System.Net;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using Dalamud.Plugin;
using Microsoft.ML;
using NoSoliciting.Properties;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace NoSoliciting.Ml {
public class MlFilter : IDisposable {
public static string? LastError { get; private set; }
private const string ManifestName = "manifest.yaml";
private const string ModelName = "model.zip";
private const string Url = "http://localhost:8000/manifest.yaml";
public uint Version { get; }
private MLContext Context { get; }
private ITransformer Model { get; }
private DataViewSchema Schema { get; }
private PredictionEngine<MessageData, MessagePrediction> PredictionEngine { get; }
private MlFilter(uint version, MLContext context, ITransformer model, DataViewSchema schema) {
this.Version = version;
this.Context = context;
this.Model = model;
this.Schema = schema;
this.PredictionEngine = this.Context.Model.CreatePredictionEngine<MessageData, MessagePrediction>(this.Model, this.Schema);
}
public MessageCategory ClassifyMessage(ushort channel, string message) {
var data = new MessageData(channel, message);
var pred = this.PredictionEngine.Predict(data);
var category = MessageCategoryExt.FromString(pred.Category);
if (category != null) {
return (MessageCategory) category;
}
PluginLog.LogWarning($"Unknown message category: {pred.Category}");
return MessageCategory.Normal;
}
public static async Task<MlFilter?> Load(Plugin plugin) {
var manifest = await DownloadManifest();
if (manifest == null) {
return null;
}
byte[]? data = null;
var localManifest = LoadCachedManifest(plugin);
if (localManifest != null && localManifest.Version == manifest.Item1.Version) {
try {
data = File.ReadAllBytes(CachedFilePath(plugin, ModelName));
} catch (IOException) {
// ignored
}
}
data ??= await DownloadModel(manifest.Item1.ModelUrl);
if (data == null) {
return null;
}
UpdateCachedFile(plugin, ModelName, data);
UpdateCachedFile(plugin, ManifestName, Encoding.UTF8.GetBytes(manifest.Item2));
var context = new MLContext();
using var stream = new MemoryStream(data);
var model = context.Model.Load(stream, out var schema);
return new MlFilter(manifest.Item1.Version, context, model, schema);
}
private static async Task<byte[]?> DownloadModel(Uri url) {
try {
using var client = new WebClient();
var data = await client.DownloadDataTaskAsync(url);
return data;
} catch (WebException e) {
PluginLog.LogError("Could not download newest model.");
PluginLog.LogError(e.ToString());
LastError = e.Message;
return null;
}
}
private static string CachedFilePath(IDalamudPlugin plugin, string name) {
var pluginFolder = Util.PluginFolder(plugin);
Directory.CreateDirectory(pluginFolder);
return Path.Combine(pluginFolder, name);
}
private static async void UpdateCachedFile(IDalamudPlugin plugin, string name, byte[] data) {
var cachePath = CachedFilePath(plugin, name);
var file = File.OpenWrite(cachePath);
await file.WriteAsync(data, 0, data.Length);
await file.FlushAsync();
file.Dispose();
}
private static async Task<Tuple<Manifest, string>?> DownloadManifest() {
try {
using var client = new WebClient();
var data = await client.DownloadStringTaskAsync(Url);
LastError = null;
return Tuple.Create(LoadYaml<Manifest>(data), data);
} catch (Exception e) when (e is WebException || e is YamlException) {
PluginLog.LogError("Could not download newest model manifest.");
PluginLog.LogError(e.ToString());
LastError = e.Message;
return null;
}
}
private static Manifest? LoadCachedManifest(IDalamudPlugin plugin) {
var manifestPath = CachedFilePath(plugin, ManifestName);
if (!File.Exists(manifestPath)) {
return null;
}
string data;
try {
data = File.ReadAllText(manifestPath);
} catch (IOException) {
return null;
}
try {
return LoadYaml<Manifest>(data);
} catch (YamlException) {
return null;
}
}
private static T LoadYaml<T>(string data) {
var de = new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
return de.Deserialize<T>(data);
}
public void Dispose() {
this.PredictionEngine.Dispose();
}
}
}

94
NoSoliciting/Ml/Models.cs Normal file
View File

@ -0,0 +1,94 @@
using System;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.ML.Data;
namespace NoSoliciting.Ml {
public class MessageData {
private static readonly Regex WardRegex = new Regex(@"w.{0,2}\d", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex PlotRegex = new Regex(@"p.{0,2}\d", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly string[] PlotWords = {
"plot",
"apartment",
"apt",
};
private static readonly Regex NumbersRegex = new Regex(@"\d{1,2}.{0,2}\d{1,2}", RegexOptions.Compiled);
private static readonly string[] TradeWords = {
"B>",
"S>",
"buy",
"sell",
};
public string? Category { get; }
public uint Channel { get; }
public string Message { get; }
public bool PartyFinder => this.Channel == 0;
public bool Shout => this.Channel == 11 || this.Channel == 30;
public bool ContainsWard => this.Message.ContainsIgnoreCase("ward") || WardRegex.IsMatch(this.Message);
public bool ContainsPlot => PlotWords.Any(word => this.Message.ContainsIgnoreCase(word)) || PlotRegex.IsMatch(this.Message);
public bool ContainsHousingNumbers => NumbersRegex.IsMatch(this.Message);
public bool ContainsTradeWords => TradeWords.Any(word => this.Message.ContainsIgnoreCase(word));
public MessageData(uint channel, string message) {
this.Channel = channel;
this.Message = message;
}
}
public class MessagePrediction {
[ColumnName("PredictedLabel")]
public string Category { get; set; } = null!;
[ColumnName("Score")]
public float[] Probabilities { get; set; } = null!;
}
public enum MessageCategory {
Trade,
FreeCompany,
Normal,
Phishing,
RmtContent,
RmtGil,
Roleplaying,
Static,
}
public static class MessageCategoryExt {
public static MessageCategory? FromString(string? category) => category switch {
"TRADE" => MessageCategory.Trade,
"FC" => MessageCategory.FreeCompany,
"NORMAL" => MessageCategory.Normal,
"PHISH" => MessageCategory.Phishing,
"RMT_C" => MessageCategory.RmtContent,
"RMT_G" => MessageCategory.RmtGil,
"RP" => MessageCategory.Roleplaying,
"STATIC" => MessageCategory.Static,
_ => null,
};
public static string Name(this MessageCategory category) => category switch {
MessageCategory.Trade => "Trades",
MessageCategory.FreeCompany => "Free Company ads",
MessageCategory.Normal => "Normal messages",
MessageCategory.Phishing => "Phishing messages",
MessageCategory.RmtContent => "RMT (content)",
MessageCategory.RmtGil => "RMT (gil)",
MessageCategory.Roleplaying => "Roleplaying",
MessageCategory.Static => "Static recruitment",
_ => throw new ArgumentException("Invalid category", nameof(category)),
};
}
}

View File

@ -3,31 +3,54 @@
<PropertyGroup>
<LangVersion>8</LangVersion>
<Nullable>enable</Nullable>
<AssemblyVersion>1.4.7</AssemblyVersion>
<FileVersion>1.4.7</FileVersion>
<AssemblyVersion>1.5.0</AssemblyVersion>
<FileVersion>1.5.0</FileVersion>
<TargetFramework>net48</TargetFramework>
<AllowUnsafeBlocks>false</AllowUnsafeBlocks>
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup>
<ItemGroup>
<Reference Include="Dalamud, Version=5.2.0.5, Culture=neutral, PublicKeyToken=null">
<Reference Include="Dalamud, Version=5.2.1.0, Culture=neutral, PublicKeyToken=null">
<HintPath>$(AppData)\XIVLauncher\addon\Hooks\Dalamud.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="ImGui.NET, Version=1.72.0.0, Culture=neutral, PublicKeyToken=null">
<HintPath>$(AppData)\XIVLauncher\addon\Hooks\ImGui.NET.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="ImGuiScene, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null">
<HintPath>$(AppData)\XIVLauncher\addon\Hooks\ImGuiScene.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Lumina, Version=2.0.0.0, Culture=neutral, PublicKeyToken=null">
<HintPath>$(AppData)\XIVLauncher\addon\Hooks\Lumina.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Lumina.Excel, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null">
<HintPath>$(AppData)\XIVLauncher\addon\Hooks\Lumina.Excel.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Newtonsoft.Json, Version=12.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed">
<HintPath>$(AppData)\XIVLauncher\addon\Hooks\Newtonsoft.Json.dll</HintPath>
<Private>False</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.ML" Version="1.5.4" />
<PackageReference Include="YamlDotNet" Version="9.1.0" />
<PackageReference Include="ILRepack.Lib.MSBuild.Task" Version="2.0.18.2" />
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
</Project>

View File

@ -3,7 +3,7 @@
"Name": "NoSoliciting",
"Description": "Hides RMT in chat and the party finder. /prmt",
"InternalName": "NoSoliciting",
"AssemblyVersion": "1.4.7",
"AssemblyVersion": "1.5.0",
"RepoUrl": "https://git.sr.ht/~jkcclemens/NoSoliciting",
"ApplicableVersion": "any",
"DalamudApiLevel": 2

View File

@ -2,54 +2,85 @@
using Dalamud.Plugin;
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Threading.Tasks;
using NoSoliciting.Ml;
namespace NoSoliciting {
public partial class Plugin : IDalamudPlugin {
private bool disposedValue;
public class Plugin : IDalamudPlugin {
private bool _disposedValue;
public string Name => "NoSoliciting";
private PluginUi ui;
private Filter filter;
private PluginUi Ui { get; set; } = null!;
private Filter Filter { get; set; } = null!;
public DalamudPluginInterface Interface { get; private set; }
public PluginConfiguration Config { get; private set; }
public Definitions Definitions { get; private set; }
public DalamudPluginInterface Interface { get; private set; } = null!;
public PluginConfiguration Config { get; private set; } = null!;
public Definitions? Definitions { get; private set; }
public MlFilter? MlFilter { get; set; }
public bool MlReady => this.Config.UseMachineLearning && this.MlFilter != null;
public bool DefsReady => !this.Config.UseMachineLearning && this.Definitions != null;
private readonly List<Message> messageHistory = new List<Message>();
public IEnumerable<Message> MessageHistory { get => this.messageHistory; }
private readonly List<Message> _messageHistory = new List<Message>();
public IEnumerable<Message> MessageHistory => this._messageHistory;
private readonly List<Message> partyFinderHistory = new List<Message>();
public IEnumerable<Message> PartyFinderHistory { get => this.partyFinderHistory; }
private readonly List<Message> _partyFinderHistory = new List<Message>();
public IEnumerable<Message> PartyFinderHistory => this._partyFinderHistory;
// ReSharper disable once MemberCanBePrivate.Global
// ReSharper disable once AutoPropertyCanBeMadeGetOnly.Local
public string AssemblyLocation { get; private set; } = Assembly.GetExecutingAssembly().Location;
public void Initialize(DalamudPluginInterface pluginInterface) {
// NOTE: THE SECRET IS TO DOWNGRADE System.Numerics.Vectors THAT'S INCLUDED WITH DALAMUD
// CRY
string path = Environment.GetEnvironmentVariable("PATH")!;
string newPath = Path.GetDirectoryName(this.AssemblyLocation)!;
Environment.SetEnvironmentVariable("PATH", $"{path};{newPath}");
this.Interface = pluginInterface ?? throw new ArgumentNullException(nameof(pluginInterface), "DalamudPluginInterface cannot be null");
this.ui = new PluginUi(this);
this.Ui = new PluginUi(this);
this.Config = this.Interface.GetPluginConfig() as PluginConfiguration ?? new PluginConfiguration();
this.Config.Initialise(this.Interface);
this.UpdateDefinitions();
this.filter = new Filter(this);
this.Filter = new Filter(this);
if (this.Config.UseMachineLearning) {
this.InitialiseMachineLearning();
}
// pre-compute the max ilvl to prevent stutter
Task.Run(async () => {
while (!this.Interface.Data.IsDataReady) {
await Task.Delay(1_000).ConfigureAwait(true);
}
FilterUtil.MaxItemLevelAttainable(this.Interface.Data);
});
this.Interface.Framework.Gui.Chat.OnCheckMessageHandled += this.filter.OnChat;
this.Interface.UiBuilder.OnBuildUi += this.ui.Draw;
this.Interface.UiBuilder.OnOpenConfigUi += this.ui.OpenSettings;
this.Interface.CommandManager.AddHandler("/prmt", new CommandInfo(OnCommand) {
this.Interface.Framework.Gui.Chat.OnCheckMessageHandled += this.Filter.OnChat;
this.Interface.UiBuilder.OnBuildUi += this.Ui.Draw;
this.Interface.UiBuilder.OnOpenConfigUi += this.Ui.OpenSettings;
this.Interface.CommandManager.AddHandler("/prmt", new CommandInfo(this.OnCommand) {
HelpMessage = "Opens the NoSoliciting configuration",
});
}
internal void InitialiseMachineLearning() {
if (this.MlFilter != null) {
return;
}
Task.Run(async () => { this.MlFilter = await MlFilter.Load(this); })
.ContinueWith(_ => PluginLog.Log("Machine learning model loaded"));
}
internal void UpdateDefinitions() {
Task.Run(async () => {
var defs = await Definitions.UpdateAndCache(this).ConfigureAwait(true);
@ -62,45 +93,46 @@ namespace NoSoliciting {
});
}
public void OnCommand(string command, string args) {
this.ui.OpenSettings(null, null);
private void OnCommand(string command, string args) {
this.Ui.OpenSettings(null, null);
}
public void AddMessageHistory(Message message) {
this.messageHistory.Insert(0, message);
this._messageHistory.Insert(0, message);
while (this.messageHistory.Count > 250) {
this.messageHistory.RemoveAt(this.messageHistory.Count - 1);
while (this._messageHistory.Count > 250) {
this._messageHistory.RemoveAt(this._messageHistory.Count - 1);
}
}
public void ClearPartyFinderHistory() {
this.partyFinderHistory.Clear();
this._partyFinderHistory.Clear();
}
public void AddPartyFinderHistory(Message message) {
this.partyFinderHistory.Add(message);
this._partyFinderHistory.Add(message);
}
protected virtual void Dispose(bool disposing) {
if (!this.disposedValue) {
if (this._disposedValue) {
return;
}
if (disposing) {
this.filter.Dispose();
this.Interface.Framework.Gui.Chat.OnCheckMessageHandled -= this.filter.OnChat;
this.Interface.UiBuilder.OnBuildUi -= this.ui.Draw;
this.Interface.UiBuilder.OnOpenConfigUi -= this.ui.OpenSettings;
this.Filter.Dispose();
this.MlFilter?.Dispose();
this.Interface.Framework.Gui.Chat.OnCheckMessageHandled -= this.Filter.OnChat;
this.Interface.UiBuilder.OnBuildUi -= this.Ui.Draw;
this.Interface.UiBuilder.OnOpenConfigUi -= this.Ui.OpenSettings;
this.Interface.CommandManager.RemoveHandler("/prmt");
}
// TODO: free unmanaged resources (unmanaged objects) and override finalizer
// TODO: set large fields to null
disposedValue = true;
}
this._disposedValue = true;
}
public void Dispose() {
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
this.Dispose(true);
GC.SuppressFinalize(this);
}
}

View File

@ -5,10 +5,13 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using NoSoliciting.Ml;
namespace NoSoliciting {
[Serializable]
public class PluginConfiguration : IPluginConfiguration {
public static readonly PluginConfiguration Default = new PluginConfiguration();
[NonSerialized]
private DalamudPluginInterface pi;
@ -16,33 +19,77 @@ namespace NoSoliciting {
[Obsolete("Use FilterStatus")]
public bool FilterChat { get; set; } = true;
[Obsolete("Use FilterStatus")]
public bool FilterFCRecruitments { get; set; } = false;
[Obsolete("Use FilterStatus")]
public bool FilterChatRPAds { get; set; } = false;
[Obsolete("Use FilterStatus")]
public bool FilterPartyFinder { get; set; } = true;
[Obsolete("Use FilterStatus")]
public bool FilterPartyFinderRPAds { get; set; } = false;
public Dictionary<string, bool> FilterStatus { get; private set; } = new Dictionary<string, bool>();
public bool AdvancedMode { get; set; } = false;
public bool AdvancedMode { get; set; }
public bool CustomChatFilter { get; set; } = false;
public bool CustomChatFilter { get; set; }
public List<string> ChatSubstrings { get; } = new List<string>();
public List<string> ChatRegexes { get; } = new List<string>();
[JsonIgnore]
public List<Regex> CompiledChatRegexes { get; private set; } = new List<Regex>();
public bool CustomPFFilter { get; set; } = false;
public bool CustomPFFilter { get; set; }
public List<string> PFSubstrings { get; } = new List<string>();
public List<string> PFRegexes { get; } = new List<string>();
[JsonIgnore]
public List<Regex> CompiledPFRegexes { get; private set; } = new List<Regex>();
public bool FilterHugeItemLevelPFs { get; set; } = false;
public bool FilterHugeItemLevelPFs { get; set; }
public bool UseMachineLearning { get; set; }
public HashSet<MessageCategory> BasicMlFilters { get; set; } = new HashSet<MessageCategory> {
MessageCategory.RmtGil,
MessageCategory.RmtContent,
MessageCategory.Phishing,
};
public Dictionary<MessageCategory, HashSet<ChatType>> MlFilters { get; set; } = new Dictionary<MessageCategory, HashSet<ChatType>> {
[MessageCategory.RmtGil] = new HashSet<ChatType> {
ChatType.Say,
},
[MessageCategory.RmtContent] = new HashSet<ChatType> {
ChatType.None,
},
[MessageCategory.Phishing] = new HashSet<ChatType> {
ChatType.TellIncoming,
},
[MessageCategory.Roleplaying] = new HashSet<ChatType> {
ChatType.None,
ChatType.Shout,
ChatType.Yell,
},
[MessageCategory.FreeCompany] = new HashSet<ChatType> {
ChatType.None,
ChatType.Shout,
ChatType.Yell,
ChatType.TellIncoming,
},
[MessageCategory.Static] = new HashSet<ChatType> {
ChatType.None,
},
[MessageCategory.Trade] = new HashSet<ChatType> {
ChatType.None,
},
};
public bool LogFilteredPfs { get; set; } = true;
public bool LogFilteredChat { get; set; } = true;
public void Initialise(DalamudPluginInterface pi) {
this.pi = pi ?? throw new ArgumentNullException(nameof(pi), "DalamudPluginInterface cannot be null");
@ -61,5 +108,25 @@ namespace NoSoliciting {
.Select(reg => new Regex(reg, RegexOptions.Compiled))
.ToList();
}
internal bool MlEnabledOn(MessageCategory category, ChatType chatType) {
HashSet<ChatType> filtered;
if (this.AdvancedMode) {
if (!this.MlFilters.TryGetValue(category, out filtered)) {
return false;
}
} else {
if (!this.BasicMlFilters.Contains(category)) {
return false;
}
if (!Default.MlFilters.TryGetValue(category, out filtered)) {
return false;
}
}
return filtered.Contains(chatType);
}
}
}

View File

@ -11,6 +11,7 @@ using System.Net;
using System.Numerics;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using NoSoliciting.Ml;
namespace NoSoliciting {
public class PluginUi {
@ -36,7 +37,7 @@ namespace NoSoliciting {
this.Plugin = plugin ?? throw new ArgumentNullException(nameof(plugin), "Plugin cannot be null");
}
public void OpenSettings(object sender, EventArgs e) {
public void OpenSettings(object? sender, EventArgs? e) {
this.ShowSettings = true;
}
@ -62,22 +63,43 @@ namespace NoSoliciting {
return;
}
if (this.Plugin.Config.AdvancedMode) {
this.DrawAdvancedSettings();
} else {
this.DrawBasicSettings();
}
var modes = new[] {
"Definition matchers (default)",
"Machine learning (experimental)",
};
var modeIndex = this.Plugin.Config.UseMachineLearning ? 1 : 0;
if (ImGui.Combo("Filter mode", ref modeIndex, modes, modes.Length)) {
this.Plugin.Config.UseMachineLearning = modeIndex == 1;
this.Plugin.Config.Save();
ImGui.Separator();
if (this.Plugin.Config.UseMachineLearning) {
this.Plugin.InitialiseMachineLearning();
}
}
var advanced = this.Plugin.Config.AdvancedMode;
if (ImGui.Checkbox("Advanced mode", ref advanced)) {
this.Plugin.Config.AdvancedMode = advanced;
this.Plugin.Config.Save();
this._resizeWindow = true;
}
ImGui.SameLine();
ImGui.Spacing();
if (!ImGui.BeginTabBar("##nosoliciting-tabs")) {
return;
}
if (this.Plugin.Config.UseMachineLearning) {
this.DrawMachineLearningConfig();
} else {
this.DrawDefinitionsConfig();
}
this.DrawOtherFilters();
ImGui.EndTabBar();
ImGui.Separator();
if (ImGui.Button("Show reporting window")) {
this.ShowReporting = true;
@ -86,34 +108,12 @@ namespace NoSoliciting {
ImGui.End();
}
private void DrawBasicSettings() {
if (this.Plugin.Definitions == null) {
private void DrawOtherFilters() {
if (!ImGui.BeginTabItem("Other filters")) {
return;
}
this.DrawCheckboxes(this.Plugin.Definitions.Chat.Values, true, "chat");
ImGui.Separator();
this.DrawCheckboxes(this.Plugin.Definitions.PartyFinder.Values, true, "Party Finder");
var filterHugeItemLevelPFs = this.Plugin.Config.FilterHugeItemLevelPFs;
// ReSharper disable once InvertIf
if (ImGui.Checkbox("Filter PFs with item level above maximum", ref filterHugeItemLevelPFs)) {
this.Plugin.Config.FilterHugeItemLevelPFs = filterHugeItemLevelPFs;
this.Plugin.Config.Save();
}
}
private void DrawAdvancedSettings() {
if (!ImGui.BeginTabBar("##nosoliciting-tabs")) {
return;
}
if (this.Plugin.Definitions != null) {
if (ImGui.BeginTabItem("Chat")) {
this.DrawCheckboxes(this.Plugin.Definitions.Chat.Values, false, "chat");
if (ImGui.CollapsingHeader("Chat filters")) {
var customChat = this.Plugin.Config.CustomChatFilter;
if (ImGui.Checkbox("Enable custom chat filters", ref customChat)) {
this.Plugin.Config.CustomChatFilter = customChat;
@ -125,15 +125,12 @@ namespace NoSoliciting {
var regexes = this.Plugin.Config.ChatRegexes;
this.DrawCustom("chat", ref substrings, ref regexes);
}
ImGui.EndTabItem();
}
if (ImGui.BeginTabItem("Party Finder")) {
this.DrawCheckboxes(this.Plugin.Definitions.PartyFinder.Values, false, "Party Finder");
if (ImGui.CollapsingHeader("Party Finder filters")) {
var filterHugeItemLevelPFs = this.Plugin.Config.FilterHugeItemLevelPFs;
if (ImGui.Checkbox("Enable built-in maximum item level filter", ref filterHugeItemLevelPFs)) {
// ReSharper disable once InvertIf
if (ImGui.Checkbox("Filter PFs with item level above maximum", ref filterHugeItemLevelPFs)) {
this.Plugin.Config.FilterHugeItemLevelPFs = filterHugeItemLevelPFs;
this.Plugin.Config.Save();
}
@ -149,6 +146,39 @@ namespace NoSoliciting {
var regexes = this.Plugin.Config.PFRegexes;
this.DrawCustom("pf", ref substrings, ref regexes);
}
}
ImGui.EndTabItem();
}
private void DrawDefsBasicSettings() {
if (this.Plugin.Definitions == null) {
return;
}
if (!ImGui.BeginTabItem("Filters")) {
return;
}
this.DrawCheckboxes(this.Plugin.Definitions.Chat.Values, true, "chat");
ImGui.Separator();
this.DrawCheckboxes(this.Plugin.Definitions.PartyFinder.Values, true, "Party Finder");
ImGui.EndTabItem();
}
private void DrawDefsAdvancedSettings() {
if (this.Plugin.Definitions != null) {
if (ImGui.BeginTabItem("Chat")) {
this.DrawCheckboxes(this.Plugin.Definitions.Chat.Values, false, "chat");
ImGui.EndTabItem();
}
if (ImGui.BeginTabItem("Party Finder")) {
this.DrawCheckboxes(this.Plugin.Definitions.PartyFinder.Values, false, "Party Finder");
ImGui.EndTabItem();
}
@ -171,9 +201,112 @@ namespace NoSoliciting {
if (ImGui.Button("Update definitions")) {
this.Plugin.UpdateDefinitions();
}
ImGui.EndTabItem();
}
}
ImGui.EndTabBar();
private void DrawMachineLearningConfig() {
if (this.Plugin.Config.AdvancedMode) {
this.DrawAdvancedMachineLearningConfig();
} else {
this.DrawBasicMachineLearningConfig();
}
if (ImGui.BeginTabItem("Model")) {
ImGui.TextUnformatted($"Version: {this.Plugin.MlFilter?.Version}");
var lastError = MlFilter.LastError;
if (lastError != null) {
ImGui.TextUnformatted($"Last error: {lastError}");
}
if (ImGui.Button("Update model")) {
this.Plugin.MlFilter = null;
this.Plugin.InitialiseMachineLearning();
}
ImGui.EndTabItem();
}
}
private void DrawBasicMachineLearningConfig() {
if (!ImGui.BeginTabItem("Filters")) {
return;
}
foreach (var category in (MessageCategory[]) Enum.GetValues(typeof(MessageCategory))) {
if (category == MessageCategory.Normal) {
continue;
}
var check = this.Plugin.Config.BasicMlFilters.Contains(category);
if (ImGui.Checkbox(category.Name(), ref check)) {
if (check) {
this.Plugin.Config.BasicMlFilters.Add(category);
} else {
this.Plugin.Config.BasicMlFilters.Remove(category);
}
this.Plugin.Config.Save();
}
}
ImGui.EndTabItem();
}
private void DrawAdvancedMachineLearningConfig() {
if (!ImGui.BeginTabItem("Filters")) {
return;
}
foreach (var category in (MessageCategory[]) Enum.GetValues(typeof(MessageCategory))) {
if (category == MessageCategory.Normal) {
continue;
}
if (!ImGui.CollapsingHeader(category.Name())) {
continue;
}
if (!this.Plugin.Config.MlFilters.ContainsKey(category)) {
this.Plugin.Config.MlFilters[category] = new HashSet<ChatType>();
}
var types = this.Plugin.Config.MlFilters[category];
void DrawTypes(ChatType type) {
var name = type == ChatType.None ? "Party Finder" : type.ToString();
var check = types.Contains(type);
if (!ImGui.Checkbox(name, ref check)) {
return;
}
if (check) {
types.Add(type);
} else {
types.Remove(type);
}
this.Plugin.Config.Save();
}
DrawTypes(ChatType.None);
foreach (var type in Filter.FilteredChatTypes) {
DrawTypes(type);
}
}
ImGui.EndTabItem();
}
private void DrawDefinitionsConfig() {
if (this.Plugin.Config.AdvancedMode) {
this.DrawDefsAdvancedSettings();
} else {
this.DrawDefsBasicSettings();
}
}
private void DrawCustom(string name, ref List<string> substrings, ref List<string> regexes) {
@ -393,11 +526,9 @@ namespace NoSoliciting {
ImGui.Text("Reporting this message will let the developer know that you think this message was incorrectly classified.");
if (message.FilterReason != null) {
ImGui.Text("Specifically, this message WAS filtered but shouldn't have been.");
} else {
ImGui.Text("Specifically, this message WAS NOT filtered but should have been.");
}
ImGui.Text(message.FilterReason != null
? "Specifically, this message WAS filtered but shouldn't have been."
: "Specifically, this message WAS NOT filtered but should have been.");
ImGui.Separator();
@ -410,11 +541,11 @@ namespace NoSoliciting {
} else {
if (ImGui.Button("Report")) {
Task.Run(async () => {
string resp = null;
string? resp = null;
try {
using var client = new WebClient();
this.LastReportStatus = ReportStatus.InProgress;
resp = await client.UploadStringTaskAsync(this.Plugin.Definitions.ReportUrl, message.ToJson()).ConfigureAwait(true);
resp = await client.UploadStringTaskAsync(this.Plugin.Definitions!.ReportUrl, message.ToJson()).ConfigureAwait(true);
} catch (Exception) {
// ignored
}

View File

@ -1,38 +0,0 @@
using System.Resources;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("NoSoliciting")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("NoSoliciting")]
[assembly: AssemblyCopyright("Copyright © 2020")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
// Setting ComVisible to false makes the types in this assembly not visible
// to COM components. If you need to access a type in this assembly from
// COM, set the ComVisible attribute to true on that type.
[assembly: ComVisible(false)]
// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("e4c12987-9064-4788-8783-be418b2c0142")]
// Version information for an assembly consists of the following four values:
//
// Major Version
// Minor Version
// Build Number
// Revision
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.4.7")]
[assembly: AssemblyFileVersion("1.4.7")]
[assembly: NeutralResourcesLanguage("en-GB")]

View File

@ -19,7 +19,7 @@ namespace NoSoliciting.Properties {
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources {
@ -75,9 +75,9 @@ namespace NoSoliciting.Properties {
///# Each subsection is a separate built-in filter that can be toggled on
///# and off. The option shown in the UI is defined [rest of string was truncated]&quot;;.
/// </summary>
internal static string default_definitions {
internal static string DefaultDefinitions {
get {
return ResourceManager.GetString("default_definitions", resourceCulture);
return ResourceManager.GetString("DefaultDefinitions", resourceCulture);
}
}
}

View File

@ -118,8 +118,7 @@
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<assembly alias="System.Windows.Forms" name="System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" />
<data name="default_definitions" type="System.Resources.ResXFileRef, System.Windows.Forms">
<data name="DefaultDefinitions" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\definitions.yaml;System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;utf-8</value>
<comment>The default definitions to be used as the last-resort fallback. Automatically included from current definitions at release.</comment>
</data>
</root>

16
NoSoliciting/Util.cs Normal file
View File

@ -0,0 +1,16 @@
using System;
using System.IO;
using Dalamud.Plugin;
namespace NoSoliciting {
public static class Util {
public static string PluginFolder(IDalamudPlugin plugin) {
return Path.Combine(new[] {
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"XIVLauncher",
"pluginConfigs",
plugin.Name,
});
}
}
}

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Microsoft.CodeAnalysis.FxCopAnalyzers" version="2.9.6" targetFramework="net48" developmentDependency="true" />
<package id="Microsoft.CodeAnalysis.VersionCheckAnalyzer" version="2.9.6" targetFramework="net48" developmentDependency="true" />
<package id="Microsoft.CodeQuality.Analyzers" version="2.9.6" targetFramework="net48" developmentDependency="true" />
<package id="Microsoft.NetCore.Analyzers" version="2.9.6" targetFramework="net48" developmentDependency="true" />
<package id="Microsoft.NetFramework.Analyzers" version="2.9.6" targetFramework="net48" developmentDependency="true" />
<package id="YamlDotNet" version="8.1.2" targetFramework="net48" />
</packages>