feat: allow specifying main filters dynamically

Main filters (e.g. RMT, RP, FC) can now be fully specified by the
definitions file, allowing entire new classes of filters to be added
without a plugin update.
This commit is contained in:
Anna 2020-08-21 13:46:42 -04:00
parent 853d39faf0
commit ec2bfc03d0
6 changed files with 226 additions and 143 deletions

View File

@ -1,4 +1,5 @@
using Dalamud.Plugin;
using Dalamud.Game.Chat;
using Dalamud.Plugin;
using System;
using System.Collections.Generic;
using System.IO;
@ -21,9 +22,11 @@ namespace NoSoliciting {
private const string URL = "https://git.sr.ht/~jkcclemens/NoSoliciting/blob/master/NoSoliciting/definitions.yaml";
public uint Version { get; private set; }
public ChatDefinitions Chat { get; private set; }
public PartyFinderDefinitions PartyFinder { get; private set; }
public GlobalDefinitions Global { 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; }
public static async Task<Definitions> UpdateAndCache(Plugin plugin) {
Definitions defs = null;
@ -128,51 +131,61 @@ namespace NoSoliciting {
}
}
internal void Initialise() {
Definition[] all = {
this.Chat.RMT,
this.Chat.FreeCompany,
this.PartyFinder.RMT,
this.Global.Roleplay,
};
internal void Initialise(Plugin plugin) {
IEnumerable<KeyValuePair<string, Definition>> defs = this.Chat.Select(e => new KeyValuePair<string, Definition>($"chat.{e.Key}", e.Value))
.Concat(this.PartyFinder.Select(e => new KeyValuePair<string, Definition>($"party_finder.{e.Key}", e.Value)));
foreach (Definition def in all) {
def.Initialise();
foreach (KeyValuePair<string, Definition> entry in defs) {
entry.Value.Initialise(entry.Key);
if (!plugin.Config.FilterStatus.TryGetValue(entry.Key, out _)) {
plugin.Config.FilterStatus[entry.Key] = entry.Value.Default;
}
}
foreach (KeyValuePair<string, Definition> entry in this.Global) {
Definition chat = entry.Value.Clone();
chat.Initialise($"chat.global.{entry.Key}");
this.Chat[$"global.{entry.Key}"] = chat;
Definition pf = entry.Value.Clone();
pf.Initialise($"party_finder.global.{entry.Key}");
this.PartyFinder[$"global.{entry.Key}"] = pf;
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;
}
}
plugin.Config.Save();
}
}
public class ChatDefinitions {
[YamlMember(Alias = "rmt")]
public Definition RMT { get; private set; }
public Definition FreeCompany { get; private set; }
}
public class PartyFinderDefinitions {
[YamlMember(Alias = "rmt")]
public Definition RMT { get; private set; }
}
public class GlobalDefinitions {
public Definition Roleplay { get; private set; }
}
public class Definition {
private bool initialised = false;
[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 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;
internal void Initialise() {
internal void Initialise(string id) {
if (this.initialised) {
return;
}
this.initialised = true;
this.Id = id ?? throw new ArgumentNullException(nameof(id), "string cannot be null");
if (!this.IgnoreCase) {
return;
}
@ -186,11 +199,15 @@ namespace NoSoliciting {
}
}
public bool Matches(string text) {
public bool Matches(XivChatType type, string text) {
if (text == null) {
throw new ArgumentNullException(nameof(text), "string cannot be null");
}
if (this.Channels.Count != 0 && !this.Channels.Contains(type)) {
return false;
}
if (this.Normalise) {
text = RMTUtil.Normalise(text);
}
@ -217,6 +234,19 @@ namespace NoSoliciting {
// matches only if likelihood is greater than or equal the threshold
return likelihood >= this.LikelihoodThreshold;
}
public Definition Clone() {
return new Definition {
RequiredMatchers = this.RequiredMatchers,
LikelyMatchers = this.LikelyMatchers,
LikelihoodThreshold = this.LikelihoodThreshold,
IgnoreCase = this.IgnoreCase,
Normalise = this.Normalise,
Channels = this.Channels,
Option = this.Option,
Default = this.Default,
};
}
}
public class Matcher {
@ -258,6 +288,11 @@ namespace NoSoliciting {
}
}
public class OptionNames {
public string Basic { get; private set; }
public string Advanced { get; private set; }
}
internal sealed class MatcherConverter : IYamlTypeConverter {
public bool Accepts(Type type) {
return type == typeof(Matcher);

View File

@ -41,7 +41,7 @@ namespace NoSoliciting {
Definitions defs = await Definitions.UpdateAndCache(this).ConfigureAwait(true);
// this shouldn't be possible, but what do I know
if (defs != null) {
defs.Initialise();
defs.Initialise(this);
this.Definitions = defs;
Definitions.LastUpdate = DateTime.Now;
}

View File

@ -11,13 +11,20 @@ namespace NoSoliciting {
public int Version { get; set; } = 1;
[Obsolete("Use EnabledFilters")]
public bool FilterChat { get; set; } = true;
[Obsolete("Use EnabledFilters")]
public bool FilterFCRecruitments { get; set; } = false;
[Obsolete("Use EnabledFilters")]
public bool FilterChatRPAds { get; set; } = false;
[Obsolete("Use EnabledFilters")]
public bool FilterPartyFinder { get; set; } = true;
[Obsolete("Use EnabledFilters")]
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 CustomChatFilter { get; set; } = false;

View File

@ -1,4 +1,5 @@
using ImGuiNET;
using Dalamud.Interface;
using ImGuiNET;
using System;
using System.Collections.Generic;
using System.Numerics;
@ -7,6 +8,7 @@ using System.Text.RegularExpressions;
namespace NoSoliciting {
public class PluginUI {
private readonly Plugin plugin;
private bool resizeWindow = false;
private bool _showSettings;
public bool ShowSettings { get => this._showSettings; set => this._showSettings = value; }
@ -26,6 +28,12 @@ namespace NoSoliciting {
}
public void DrawSettings() {
if (this.resizeWindow) {
this.resizeWindow = false;
ImGui.SetNextWindowSize(new Vector2(this.plugin.Config.AdvancedMode ? 600 : 0, 0));
} else {
ImGui.SetNextWindowSize(new Vector2(0, 0), ImGuiCond.FirstUseEver);
}
if (ImGui.Begin($"{this.plugin.Name} settings", ref this._showSettings)) {
if (this.plugin.Config.AdvancedMode) {
this.DrawAdvancedSettings();
@ -39,6 +47,7 @@ namespace NoSoliciting {
if (ImGui.Checkbox("Advanced mode", ref advanced)) {
this.plugin.Config.AdvancedMode = advanced;
this.plugin.Config.Save();
resizeWindow = true;
}
ImGui.End();
@ -46,105 +55,55 @@ namespace NoSoliciting {
}
private void DrawBasicSettings() {
ImGui.SetWindowSize(new Vector2(250, 225));
bool filterChat = this.plugin.Config.FilterChat;
if (ImGui.Checkbox("Filter RMT from chat", ref filterChat)) {
this.plugin.Config.FilterChat = filterChat;
this.plugin.Config.Save();
if (this.plugin.Definitions == null) {
return;
}
bool filterFC = this.plugin.Config.FilterFCRecruitments;
if (ImGui.Checkbox("Filter FC recruitments from chat", ref filterFC)) {
this.plugin.Config.FilterFCRecruitments = filterFC;
this.plugin.Config.Save();
}
bool filterChatRP = this.plugin.Config.FilterChatRPAds;
if (ImGui.Checkbox("Filter RP ads from chat", ref filterChatRP)) {
this.plugin.Config.FilterChatRPAds = filterChatRP;
this.plugin.Config.Save();
}
this.DrawCheckboxes(this.plugin.Definitions.Chat.Values, true, "chat");
ImGui.Separator();
bool filterPartyFinder = this.plugin.Config.FilterPartyFinder;
if (ImGui.Checkbox("Filter RMT from Party Finder", ref filterPartyFinder)) {
this.plugin.Config.FilterPartyFinder = filterPartyFinder;
this.plugin.Config.Save();
}
bool filterPFRP = this.plugin.Config.FilterPartyFinderRPAds;
if (ImGui.Checkbox("Filter RP ads from Party Finder", ref filterPFRP)) {
this.plugin.Config.FilterPartyFinderRPAds = filterPFRP;
this.plugin.Config.Save();
}
this.DrawCheckboxes(this.plugin.Definitions.PartyFinder.Values, true, "Party Finder");
}
private void DrawAdvancedSettings() {
ImGui.SetWindowSize(new Vector2(600, 450));
if (ImGui.BeginTabBar("##nosoliciting-tabs")) {
if (ImGui.BeginTabItem("Chat")) {
bool filterChat = this.plugin.Config.FilterChat;
if (ImGui.Checkbox("Enable built-in RMT filter", ref filterChat)) {
this.plugin.Config.FilterChat = filterChat;
this.plugin.Config.Save();
if (this.plugin.Definitions != null) {
if (ImGui.BeginTabItem("Chat")) {
this.DrawCheckboxes(this.plugin.Definitions.Chat.Values, false, "chat");
bool customChat = this.plugin.Config.CustomChatFilter;
if (ImGui.Checkbox("Enable custom chat filters", ref customChat)) {
this.plugin.Config.CustomChatFilter = customChat;
this.plugin.Config.Save();
}
if (this.plugin.Config.CustomChatFilter) {
List<string> substrings = this.plugin.Config.ChatSubstrings;
List<string> regexes = this.plugin.Config.ChatRegexes;
this.DrawCustom("chat", ref substrings, ref regexes);
}
ImGui.EndTabItem();
}
bool filterFC = this.plugin.Config.FilterFCRecruitments;
if (ImGui.Checkbox("Enable built-in FC recruitment filter", ref filterFC)) {
this.plugin.Config.FilterFCRecruitments = filterFC;
this.plugin.Config.Save();
if (ImGui.BeginTabItem("Party Finder")) {
this.DrawCheckboxes(this.plugin.Definitions.PartyFinder.Values, false, "Party Finder");
bool customPF = this.plugin.Config.CustomPFFilter;
if (ImGui.Checkbox("Enable custom Party Finder filters", ref customPF)) {
this.plugin.Config.CustomPFFilter = customPF;
this.plugin.Config.Save();
}
if (this.plugin.Config.CustomPFFilter) {
List<string> substrings = this.plugin.Config.PFSubstrings;
List<string> regexes = this.plugin.Config.PFRegexes;
this.DrawCustom("pf", ref substrings, ref regexes);
}
ImGui.EndTabItem();
}
bool filterChatRP = this.plugin.Config.FilterChatRPAds;
if (ImGui.Checkbox("Enable built-in RP ad filter", ref filterChatRP)) {
this.plugin.Config.FilterChatRPAds = filterChatRP;
this.plugin.Config.Save();
}
bool customChat = this.plugin.Config.CustomChatFilter;
if (ImGui.Checkbox("Enable custom chat filters", ref customChat)) {
this.plugin.Config.CustomChatFilter = customChat;
this.plugin.Config.Save();
}
if (this.plugin.Config.CustomChatFilter) {
List<string> substrings = this.plugin.Config.ChatSubstrings;
List<string> regexes = this.plugin.Config.ChatRegexes;
this.DrawCustom("chat", ref substrings, ref regexes);
}
ImGui.EndTabItem();
}
if (ImGui.BeginTabItem("Party Finder")) {
bool filterPartyFinder = this.plugin.Config.FilterPartyFinder;
if (ImGui.Checkbox("Enable built-in Party Finder RMT filter", ref filterPartyFinder)) {
this.plugin.Config.FilterPartyFinder = filterPartyFinder;
this.plugin.Config.Save();
}
bool filterPFRP = this.plugin.Config.FilterPartyFinderRPAds;
if (ImGui.Checkbox("Enable built-in Party Finder RP filter", ref filterPFRP)) {
this.plugin.Config.FilterPartyFinderRPAds = filterPFRP;
this.plugin.Config.Save();
}
bool customPF = this.plugin.Config.CustomPFFilter;
if (ImGui.Checkbox("Enable custom Party Finder filters", ref customPF)) {
this.plugin.Config.CustomPFFilter = customPF;
this.plugin.Config.Save();
}
if (this.plugin.Config.CustomPFFilter) {
List<string> substrings = this.plugin.Config.PFSubstrings;
List<string> regexes = this.plugin.Config.PFRegexes;
this.DrawCustom("pf", ref substrings, ref regexes);
}
ImGui.EndTabItem();
}
if (ImGui.BeginTabItem("Definitions")) {
@ -177,20 +136,24 @@ namespace NoSoliciting {
if (ImGui.BeginChild($"##{name}-substrings", new Vector2(0, 175))) {
for (int i = 0; i < substrings.Count; i++) {
string input = substrings[i];
if (ImGui.InputText($"##{name}-substring-{i}", ref input, 100)) {
if (ImGui.InputText($"##{name}-substring-{i}", ref input, 1_000)) {
if (input.Length != 0) {
substrings[i] = input;
}
}
ImGui.SameLine();
if (ImGui.Button($"Remove##{name}-substring-{i}-remove")) {
ImGui.PushFont(UiBuilder.IconFont);
if (ImGui.Button($"{FontAwesomeIcon.Trash.ToIconString()}##{name}-substring-{i}-remove")) {
substrings.RemoveAt(i);
}
ImGui.PopFont();
}
if (ImGui.Button($"Add##{name}-substring-add")) {
ImGui.PushFont(UiBuilder.IconFont);
if (ImGui.Button($"{FontAwesomeIcon.Plus.ToIconString()}##{name}-substring-add")) {
substrings.Add("");
}
ImGui.PopFont();
ImGui.EndChild();
}
@ -201,7 +164,7 @@ namespace NoSoliciting {
if (ImGui.BeginChild($"##{name}-regexes", new Vector2(0, 175))) {
for (int i = 0; i < regexes.Count; i++) {
string input = regexes[i];
if (ImGui.InputText($"##{name}-regex-{i}", ref input, 100)) {
if (ImGui.InputText($"##{name}-regex-{i}", ref input, 1_000)) {
bool valid = true;
try {
_ = new Regex(input);
@ -213,14 +176,18 @@ namespace NoSoliciting {
}
}
ImGui.SameLine();
if (ImGui.Button($"Remove##{name}-regex-{i}-remove")) {
ImGui.PushFont(UiBuilder.IconFont);
if (ImGui.Button($"{FontAwesomeIcon.Trash.ToIconString()}##{name}-regex-{i}-remove")) {
regexes.RemoveAt(i);
}
ImGui.PopFont();
}
if (ImGui.Button($"Add##{name}-regex-add")) {
ImGui.PushFont(UiBuilder.IconFont);
if (ImGui.Button($"{FontAwesomeIcon.Plus.ToIconString()}##{name}-regex-add")) {
regexes.Add("");
}
ImGui.PopFont();
ImGui.EndChild();
}
@ -231,5 +198,17 @@ namespace NoSoliciting {
this.plugin.Config.Save();
}
}
private void DrawCheckboxes(IEnumerable<Definition> defs, bool basic, string labelFillIn) {
foreach (Definition def in defs) {
this.plugin.Config.FilterStatus.TryGetValue(def.Id, out bool enabled);
string label = basic ? def.Option.Basic : def.Option.Advanced;
label = label.Replace("{}", labelFillIn);
if (ImGui.Checkbox(label, ref enabled)) {
this.plugin.Config.FilterStatus[def.Id] = enabled;
this.plugin.Config.Save();
}
}
}
}
}

View File

@ -3,7 +3,6 @@ using Dalamud.Game.Chat.SeStringHandling;
using Dalamud.Game.Internal.Network;
using Dalamud.Plugin;
using System;
using System.IO;
using System.Runtime.InteropServices;
namespace NoSoliciting {
@ -23,11 +22,6 @@ namespace NoSoliciting {
return;
}
bool anyFilter = this.plugin.Config.FilterPartyFinder || this.plugin.Config.FilterPartyFinderRPAds || this.plugin.Config.CustomPFFilter;
if (!anyFilter) {
return;
}
// only look at packets coming in
if (direction != NetworkMessageDirection.ZoneDown) {
return;
@ -53,10 +47,12 @@ namespace NoSoliciting {
bool filter = false;
// check for RMT if enabled
filter |= this.plugin.Config.FilterPartyFinder && this.plugin.Definitions.PartyFinder.RMT.Matches(desc);
// check for RP if enabled
filter |= this.plugin.Config.FilterPartyFinderRPAds && this.plugin.Definitions.Global.Roleplay.Matches(desc);
foreach (Definition def in this.plugin.Definitions.PartyFinder.Values) {
filter |= this.plugin.Config.FilterStatus.TryGetValue(def.Id, out bool enabled)
&& enabled
&& def.Matches(XivChatType.None, desc);
}
// check for custom filters if enabled
filter |= this.plugin.Config.CustomPFFilter && PartyFinder.MatchesCustomFilters(desc, this.plugin.Config);
@ -99,12 +95,12 @@ namespace NoSoliciting {
bool filter = false;
// check for RMT if enabled
filter |= this.plugin.Config.FilterChat && this.plugin.Definitions.Chat.RMT.Matches(text);
// check for RP ads if enabled
filter |= this.plugin.Config.FilterChatRPAds && this.plugin.Definitions.Global.Roleplay.Matches(text);
// check for FC recruitment if enabled
filter |= this.plugin.Config.FilterFCRecruitments && this.plugin.Definitions.Chat.FreeCompany.Matches(text);
foreach (Definition def in this.plugin.Definitions.Chat.Values) {
filter |= this.plugin.Config.FilterStatus.TryGetValue(def.Id, out bool enabled)
&& enabled
&& def.Matches(type, text);
}
// check for custom filters if enabled
filter |= this.plugin.Config.CustomChatFilter && Chat.MatchesCustomFilters(text, this.plugin.Config);

View File

@ -1,7 +1,61 @@
version: 2
# This file defines the filters that NoSoliciting will use for
# built-in filters.
# The version should be incremented for each commit including changes
# to this file.
# There are three main sections: chat, party_finder, and global. The
# chat and party_finder sections are for their respective areas (the
# chat log and the Party Finder window), and the global section
# applies to both.
# Each subsection is a separate built-in filter that can be toggled on
# and off. The option shown in the UI is defined in the
# subsection. For global subsections, {} can be inserted into the
# option name to substitute either "chat" or "Party Finder" as
# appropriate.
# Subsections can have ignore_case (defaults to false) and normalise
# (defaults to true) set. ignore_case will ignore casing for matching
# against the matchers, and normalise will normalise text prior to
# matching. Text normalisation consists of turning FFXIV-specific
# unicode symbols into normal ASCII characters and running a NFKD
# unicode decomposition on the result.
# Subsections also may filter based on channels with the channels key.
# A list of channels may be specified, and the message will be ignored
# if not in one of the specified channels. For the Party Finder, the
# channel is always None. An empty list (or missing channels key) will
# ignore the channel.
# Each subsection may specify whether it is enabled by default with the
# default key. This should be used sparingly. This defaults to false.
# The real meat of the file is the matchers. There are two types of
# matchers: required and likely. Both types have categories of strings
# or regular expressions that should match. For required matchers, at
# least one string or regex should match in *all* categories. For
# likely matchers, at least one string or regex should match in the
# value of likelihood_threshold (or greater) categories.
# If both required and likely matchers are specified, they both must
# match. This means that all the categories of the required matchers
# must find a match, *and* that at least likelihood_threshold matchers
# must find a match in likely_matchers.
# Substring matchers are faster than regular expressions and are
# specified just by using a string. Regular expression matchers are
# slower but more flexible, and they are specified by using a regex
# key, as can be seen below.
version: 3
chat:
rmt:
option:
basic: Filter RMT from chat
advanced: Enable built-in chat RMT filter
default: true
required_matchers:
- - "4KGOLD"
- "We have sufficient stock"
@ -33,7 +87,13 @@ chat:
- "offers Fantasia"
- "finalfantasyxiv.com-se.ru" # phishing
free_company:
option:
basic: Filter FC recruitments from chat
advanced: Enable built-in chat FC recruitment filter
ignore_case: true
channels:
- shout
- yell
likelihood_threshold: 3
likely_matchers:
# mentions free company
@ -60,7 +120,6 @@ chat:
# has common keywords
- - family
- community
- new
- veteran
- seasoned
- help
@ -68,6 +127,10 @@ chat:
party_finder:
rmt:
option:
basic: Filter RMT from Party Finder
advanced: Enable built-in Party Finder RMT filter
default: true
ignore_case: true
required_matchers:
# discord
@ -88,6 +151,9 @@ party_finder:
global:
roleplay:
option:
basic: 'Filter RP advertisements from {}'
advanced: 'Enable built-in {} RP filter'
ignore_case: true
required_matchers:
- - regex: 'w(ard)?\s*\d+[\s/,]*(p(lot)?|a(pt\.?|partment)?)\s*\d+'