Repository: thelounge/thelounge (ts)

Issue:
highlights also needs an exclude list
Currently it is impossible to silence specific notifications that include your nick because there are no negative-highlight-matches.

We need a list of words to be excluded from notifications that passed the current include list, so we remove the false positives.

Imagine that you have bots that may notify about build status or commit messages made by yourself, and you are high-likely to not want to be notified about your own actions.

Pull request description:
Highlight exceptions
This adds an exception/exclude list for strings that should _not_ trigger highlights. The code strips IRC formatting before testing messages for the exceptions, so that the given strings match colored/styled messages as well. Fixes #3994.

I basically duplicated the highlight code and reversed it.

Both custom highlight settings fields now have labels and help text:

![image](https://user-images.githubusercontent.com/2070503/88391991-890b0280-cdbb-11ea-9cb6-f6f423c66270.png)


Patch:
diff --git a/client/components/Windows/Settings.vue b/client/components/Windows/Settings.vue
index ce9d3865..394cb160 100644
--- a/client/components/Windows/Settings.vue
+++ b/client/components/Windows/Settings.vue
@@ -351,8 +351,15 @@ This may break orientation if your browser does not support that."
 
 			<div v-if="!$store.state.serverConfiguration.public && $store.state.settings.advanced">
 				<label class="opt">
-					<label for="highlights" class="sr-only">
-						Custom highlights (comma-separated keywords)
+					<label for="highlights" class="opt">
+						Custom highlights
+						<span
+							class="tooltipped tooltipped-n tooltipped-no-delay"
+							aria-label="If a message contains any of these comma-separated 
+expressions, it will trigger a highlight."
+						>
+							<button class="extra-help" />
+						</span>
 					</label>
 					<input
 						id="highlights"
@@ -360,7 +367,31 @@ This may break orientation if your browser does not support that."
 						type="text"
 						name="highlights"
 						class="input"
-						placeholder="Custom highlights (comma-separated keywords)"
+						placeholder="Comma-separated, e.g.: word, some more words, anotherword"
+					/>
+				</label>
+			</div>
+
+			<div v-if="!$store.state.serverConfiguration.public && $store.state.settings.advanced">
+				<label class="opt">
+					<label for="highlightExceptions" class="opt">
+						Highlight exceptions
+						<span
+							class="tooltipped tooltipped-n tooltipped-no-delay"
+							aria-label="If a message contains any of these comma-separated 
+expressions, it will not trigger a highlight even if it contains 
+your nickname or expressions defined in custom highlights."
+						>
+							<button class="extra-help" />
+						</span>
+					</label>
+					<input
+						id="highlightExceptions"
+						:value="$store.state.settings.highlightExceptions"
+						type="text"
+						name="highlightExceptions"
+						class="input"
+						placeholder="Comma-separated, e.g.: word, some more words, anotherword"
 					/>
 				</label>
 			</div>
diff --git a/client/js/settings.js b/client/js/settings.js
index b1d5308a..43bf2cfe 100644
--- a/client/js/settings.js
+++ b/client/js/settings.js
@@ -46,6 +46,10 @@ export const config = normalizeConfig({
 		default: "",
 		sync: "always",
 	},
+	highlightExceptions: {
+		default: "",
+		sync: "always",
+	},
 	awayMessage: {
 		default: "",
 		sync: "always",
diff --git a/src/client.js b/src/client.js
index 0793330d..6f9fd023 100644
--- a/src/client.js
+++ b/src/client.js
@@ -62,6 +62,7 @@ function Client(manager, name, config = {}) {
 		manager: manager,
 		messageStorage: [],
 		highlightRegex: null,
+		highlightExceptionRegex: null,
 	});
 
 	const client = this;
@@ -424,30 +425,32 @@ Client.prototype.inputLine = function (data) {
 };
 
 Client.prototype.compileCustomHighlights = function () {
-	const client = this;
+	this.highlightRegex = compileHighlightRegex(this.config.clientSettings.highlights);
+	this.highlightExceptionRegex = compileHighlightRegex(
+		this.config.clientSettings.highlightExceptions
+	);
+};
 
-	if (typeof client.config.clientSettings.highlights !== "string") {
-		client.highlightRegex = null;
-		return;
+function compileHighlightRegex(customHighlightString) {
+	if (typeof customHighlightString !== "string") {
+		return null;
 	}
 
-	// Ensure we don't have empty string in the list of highlights
-	// otherwise, users get notifications for everything
-	const highlightsTokens = client.config.clientSettings.highlights
+	// Ensure we don't have empty strings in the list of highlights
+	const highlightsTokens = customHighlightString
 		.split(",")
 		.map((highlight) => escapeRegExp(highlight.trim()))
 		.filter((highlight) => highlight.length > 0);
 
 	if (highlightsTokens.length === 0) {
-		client.highlightRegex = null;
-		return;
+		return null;
 	}
 
-	client.highlightRegex = new RegExp(
+	return new RegExp(
 		`(?:^|[ .,+!?|/:<>(){}'"@&~-])(?:${highlightsTokens.join("|")})(?:$|[ .,+!?|/:<>(){}'"-])`,
 		"i"
 	);
-};
+}
 
 Client.prototype.more = function (data) {
 	const client = this;
diff --git a/src/plugins/irc-events/link.js b/src/plugins/irc-events/link.js
index 4f68f9df..841f0500 100644
--- a/src/plugins/irc-events/link.js
+++ b/src/plugins/irc-events/link.js
@@ -5,21 +5,17 @@ const got = require("got");
 const URL = require("url").URL;
 const mime = require("mime-types");
 const Helper = require("../../helper");
-const cleanIrcMessage = require("../../../client/js/helpers/ircmessageparser/cleanIrcMessage");
 const {findLinksWithSchema} = require("../../../client/js/helpers/ircmessageparser/findLinks");
 const storage = require("../storage");
 const currentFetchPromises = new Map();
 const imageTypeRegex = /^image\/.+/;
 const mediaTypeRegex = /^(audio|video)\/.+/;
 
-module.exports = function (client, chan, msg) {
+module.exports = function (client, chan, msg, cleanText) {
 	if (!Helper.config.prefetch) {
 		return;
 	}
 
-	// Remove all IRC formatting characters before searching for links
-	const cleanText = cleanIrcMessage(msg.text);
-
 	msg.previews = findLinksWithSchema(cleanText).reduce((cleanLinks, link) => {
 		const url = normalizeURL(link.link);
 
diff --git a/src/plugins/irc-events/message.js b/src/plugins/irc-events/message.js
index a2ff0081..664227c2 100644
--- a/src/plugins/irc-events/message.js
+++ b/src/plugins/irc-events/message.js
@@ -115,6 +115,9 @@ module.exports = function (irc, network) {
 			msg.showInActive = true;
 		}
 
+		// remove IRC formatting for custom highlight testing
+		const cleanMessage = cleanIrcMessage(data.message);
+
 		// Self messages in channels are never highlighted
 		// Non-self messages are highlighted as soon as the nick is detected
 		if (!msg.highlight && !msg.self) {
@@ -122,10 +125,15 @@ module.exports = function (irc, network) {
 
 			// If we still don't have a highlight, test against custom highlights if there's any
 			if (!msg.highlight && client.highlightRegex) {
-				msg.highlight = client.highlightRegex.test(data.message);
+				msg.highlight = client.highlightRegex.test(cleanMessage);
 			}
 		}
 
+		// if highlight exceptions match, do not highlight at all
+		if (msg.highlight && client.highlightExceptionRegex) {
+			msg.highlight = !client.highlightExceptionRegex.test(cleanMessage);
+		}
+
 		if (data.group) {
 			msg.statusmsgGroup = data.group;
 		}
@@ -140,7 +148,7 @@ module.exports = function (irc, network) {
 
 		// No prefetch URLs unless are simple MESSAGE or ACTION types
 		if ([Msg.Type.MESSAGE, Msg.Type.ACTION].includes(data.type)) {
-			LinkPrefetch(client, chan, msg);
+			LinkPrefetch(client, chan, msg, cleanMessage);
 		}
 
 		chan.pushMessage(client, msg, !msg.self);
@@ -148,7 +156,7 @@ module.exports = function (irc, network) {
 		// Do not send notifications for messages older than 15 minutes (znc buffer for example)
 		if (msg.highlight && (!data.time || data.time > Date.now() - 900000)) {
 			let title = chan.name;
-			let body = cleanIrcMessage(data.message);
+			let body = cleanMessage;
 
 			if (msg.type === Msg.Type.ACTION) {
 				// For actions, do not include colon in the message
diff --git a/src/server.js b/src/server.js
index f9bb0dc4..5c941db1 100644
--- a/src/server.js
+++ b/src/server.js
@@ -621,7 +621,7 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
 
 				client.save();
 
-				if (newSetting.name === "highlights") {
+				if (newSetting.name === "highlights" || newSetting.name === "highlightExceptions") {
 					client.compileCustomHighlights();
 				} else if (newSetting.name === "awayMessage") {
 					if (typeof newSetting.value !== "string") {


Test patch:
diff --git a/test/plugins/link.js b/test/plugins/link.js
index b22937a8..a7df98b5 100644
--- a/test/plugins/link.js
+++ b/test/plugins/link.js
@@ -49,7 +49,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 			text: url,
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		expect(message.previews).to.deep.equal([
 			{
@@ -86,7 +86,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 			text: url,
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		expect(message.previews).to.deep.equal([
 			{
@@ -122,7 +122,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 			text: url,
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		app.get("/truncate", function (req, res) {
 			res.send(
@@ -146,7 +146,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 			text: "http://localhost:" + this.port + "/basic-og",
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		app.get("/basic-og", function (req, res) {
 			res.send("<title>test</title><meta property='og:title' content='opengraph test'>");
@@ -163,7 +163,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 			text: "http://localhost:" + this.port + "/duplicate-tags",
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		app.get("/duplicate-tags", function (req, res) {
 			res.send(
@@ -183,7 +183,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 			text: "http://localhost:" + this.port + "/description-og",
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		app.get("/description-og", function (req, res) {
 			res.send(
@@ -203,7 +203,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 			text: "http://localhost:" + this.port + "/thumb",
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		app.get("/thumb", function (req, res) {
 			res.send(
@@ -249,7 +249,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 				text: "http://localhost:" + port + "/thumb",
 			});
 
-			link(this.irc, this.network.channels[0], message);
+			link(this.irc, this.network.channels[0], message, message.text);
 
 			this.irc.once("msg:preview", function (data) {
 				expect(data.preview.head).to.equal("Google");
@@ -276,7 +276,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 				text: "http://localhost:" + port + "/thumb",
 			});
 
-			link(this.irc, this.network.channels[0], message);
+			link(this.irc, this.network.channels[0], message, message.text);
 
 			this.irc.once("msg:preview", function (data) {
 				expect(data.preview.head).to.equal("Google");
@@ -292,7 +292,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 			text: "http://localhost:" + this.port + "/thumb-image-src",
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		app.get("/thumb-image-src", function (req, res) {
 			res.send(
@@ -314,7 +314,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 			text: "http://localhost:" + this.port + "/thumb-image-src",
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		app.get("/thumb-image-src", function (req, res) {
 			res.send("<link rel='image_src' href='//localhost:" + port + "/real-test-image.png'>");
@@ -334,7 +334,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 			text: "http://localhost:" + this.port + "/relative-thumb",
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		app.get("/relative-thumb", function (req, res) {
 			res.send(
@@ -358,7 +358,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 			text: "http://localhost:" + this.port + "/thumb-no-title",
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		app.get("/thumb-no-title", function (req, res) {
 			res.send(
@@ -384,7 +384,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 			text: "http://localhost:" + this.port + "/body-no-title",
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		app.get("/body-no-title", function (req, res) {
 			res.send("<meta name='description' content='hello world'>");
@@ -405,7 +405,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 			text: "http://localhost:" + this.port + "/thumb-404",
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		app.get("/thumb-404", function (req, res) {
 			res.send(
@@ -429,7 +429,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 			text: "http://localhost:" + port + "/real-test-image.png",
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		this.irc.once("msg:preview", function (data) {
 			expect(data.preview.type).to.equal("image");
@@ -448,7 +448,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 			text: "http://localhost:" + port + "/one http://localhost:" + this.port + "/two",
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		expect(message.previews).to.eql([
 			{
@@ -511,7 +511,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 			text: "http://localhost:" + this.port + "/language-check",
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 	});
 
 	it("should send accept text/html for initial request", function (done) {
@@ -527,7 +527,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 			text: "http://localhost:" + this.port + "/accept-header-html",
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 	});
 
 	it("should send accept */* for meta image", function (done) {
@@ -551,7 +551,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 			text: "http://localhost:" + port + "/accept-header-thumb",
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 	});
 
 	it("should not add slash to url", function (done) {
@@ -560,7 +560,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 			text: "http://localhost:" + port + "",
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		this.irc.once("msg:preview", function (data) {
 			expect(data.preview.link).to.equal("http://localhost:" + port + "");
@@ -591,7 +591,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 				"/unicodeq/?q=🙈-emoji-test",
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		app.get("/unicode/:q", function (req, res) {
 			res.send(`<title>${req.params.q}</title>`);
@@ -629,7 +629,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 			text: `//localhost:${port} localhost:${port} //localhost:${port}/test localhost:${port}/test`,
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		expect(message.previews).to.be.empty;
 	});
@@ -647,7 +647,7 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 				"",
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		expect(message.previews).to.deep.equal([
 			{
@@ -695,9 +695,9 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 
 		this.irc.config.browser.language = "very nice language";
 
-		link(this.irc, this.network.channels[0], message);
-		link(this.irc, this.network.channels[0], message);
-		process.nextTick(() => link(this.irc, this.network.channels[0], message));
+		link(this.irc, this.network.channels[0], message, message.text);
+		link(this.irc, this.network.channels[0], message, message.text);
+		process.nextTick(() => link(this.irc, this.network.channels[0], message, message.text));
 
 		app.get("/basic-og-once", function (req, res) {
 			requests++;
@@ -734,11 +734,11 @@ Vivamus bibendum vulputate tincidunt. Sed vitae ligula felis.`;
 		let responses = 0;
 
 		this.irc.config.browser.language = "first language";
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		setTimeout(() => {
 			this.irc.config.browser.language = "second language";
-			link(this.irc, this.network.channels[0], message);
+			link(this.irc, this.network.channels[0], message, message.text);
 		}, 100);
 
 		app.get("/basic-og-once-lang", function (req, res) {
diff --git a/test/plugins/storage.js b/test/plugins/storage.js
index 9d3bbc8f..c8c4a14d 100644
--- a/test/plugins/storage.js
+++ b/test/plugins/storage.js
@@ -76,7 +76,7 @@ describe("Image storage", function () {
 			text: "http://localhost:" + port + "/thumb",
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		this.app.get("/thumb", function (req, res) {
 			res.send(
@@ -100,7 +100,7 @@ describe("Image storage", function () {
 			text: "http://localhost:" + port + "/real-test-image.png",
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		this.irc.once("msg:preview", function (data) {
 			expect(data.preview.type).to.equal("image");
@@ -124,7 +124,7 @@ describe("Image storage", function () {
 			);
 		});
 
-		link(this.irc, this.network.channels[0], message);
+		link(this.irc, this.network.channels[0], message, message.text);
 
 		this.irc.once("msg:preview", function (data) {
 			expect(data.preview.type).to.equal("link");
diff --git a/test/tests/customhighlights.js b/test/tests/customhighlights.js
index 86aa3883..64147d67 100644
--- a/test/tests/customhighlights.js
+++ b/test/tests/customhighlights.js
@@ -22,7 +22,10 @@ describe("Custom highlights", function () {
 		},
 		"test",
 		{
-			clientSettings: {highlights: "foo, @all,   sp ace   , 고"},
+			clientSettings: {
+				highlights: "foo, @all,   sp ace   , 고",
+				highlightExceptions: "foo bar, bar @all, test sp ace test",
+			},
 		}
 	);
 
@@ -96,4 +99,53 @@ describe("Custom highlights", function () {
 		client.compileCustomHighlights();
 		expect(client.highlightRegex).to.be.null;
 	});
+
+	// tests for highlight exceptions
+	it("should NOT highlight due to highlight exceptions", function () {
+		const teststrings = [
+			"foo bar baz",
+			"test foo bar",
+			"foo bar @all test",
+			"with a test sp ace test",
+		];
+
+		for (const teststring of teststrings) {
+			expect(teststring).to.match(client.highlightExceptionRegex);
+		}
+	});
+
+	it("should highlight regardless of highlight exceptions", function () {
+		const teststrings = [
+			"Hey foo hello",
+			"hey Foo: hi",
+			"hey Foo, hi",
+			"<foo> testing",
+			"foo",
+			"hey @all test",
+			"testing foo's stuff",
+			'"foo"',
+			'"@all"',
+			"foo!",
+			"www.foo.bar",
+			"www.bar.foo/page",
+			"고",
+			"test 고",
+			"고!",
+			"www.고.com",
+			"hey @Foo",
+			"hey ~Foo",
+			"hey +Foo",
+			"hello &foo",
+			"@all",
+			"@all wtf",
+			"wtfbar @all",
+			"@@all",
+			"@고",
+			"f00 sp ace: bar",
+		];
+
+		for (const teststring of teststrings) {
+			expect(teststring).to.not.match(client.highlightExceptionRegex);
+		}
+	});
 });