From d7a6783e6aa0c05d01a443038111b5526cab59e5 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Mon, 20 Apr 2026 12:21:16 +0100 Subject: [PATCH] feat: add self flag to message events for echo-message and znc.in/self-message When the echo-message cap (or the znc.in/self-message cap) is active, the server sends back a copy of every PRIVMSG, NOTICE, ACTION, and TAGMSG the client sends. These echoed messages arrive with the client's own nick as the prefix and are currently indistinguishable from any other message at the framework level, forcing each consumer to re-implement the same nick-comparison logic. This commit adds a boolean 'self' field to the privmsg, notice, tagmsg, and action event payloads: - self: true when echo-message (or znc.in/self-message) is enabled AND the sending nick matches the connected user's nick - self: false in all other cases The comparison is done via client.caseCompare() so it respects the server's CASEMAPPING setting. Tests added in test/commands/handlers/messaging.test.js cover all four event types, both cap variants, the cap-absent case, a mismatched nick, and case-insensitive comparison. --- src/commands/handlers/messaging.js | 24 ++- test/commands/handlers/messaging.test.js | 202 +++++++++++++++++++++++ 2 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 test/commands/handlers/messaging.test.js diff --git a/src/commands/handlers/messaging.js b/src/commands/handlers/messaging.js index afc967c9..e50a2042 100644 --- a/src/commands/handlers/messaging.js +++ b/src/commands/handlers/messaging.js @@ -6,6 +6,18 @@ const _ = { }; const util = require('util'); +// Returns true when the message was sent by our own client and echoed back by the server. +// This happens when either echo-message or znc.in/self-message cap is active. +function isSelfMessage(command, handler) { + const cap = handler.network.cap; + const echoActive = cap.isEnabled('echo-message') || cap.isEnabled('znc.in/self-message'); + if (!echoActive) { + return false; + } + const ownNick = handler.client.user.nick; + return !!(ownNick && command.nick && handler.client.caseCompare(command.nick, ownNick)); +} + const handlers = { NOTICE: function(command, handler) { const time = command.getServerTime(); @@ -44,7 +56,8 @@ const handlers = { tags: command.tags, time: time, account: command.getTag('account'), - batch: command.batch + batch: command.batch, + self: isSelfMessage(command, handler) }); } }, @@ -76,7 +89,8 @@ const handlers = { tags: command.tags, time: time, account: command.getTag('account'), - batch: command.batch + batch: command.batch, + self: isSelfMessage(command, handler) }); } else if (ctcp_command === 'VERSION' && handler.connection.options.version) { handler.connection.write(util.format( @@ -111,7 +125,8 @@ const handlers = { tags: command.tags, time: time, account: command.getTag('account'), - batch: command.batch + batch: command.batch, + self: isSelfMessage(command, handler) }); } }, @@ -127,7 +142,8 @@ const handlers = { tags: command.tags, time: time, account: command.getTag('account'), - batch: command.batch + batch: command.batch, + self: isSelfMessage(command, handler) }); }, diff --git a/test/commands/handlers/messaging.test.js b/test/commands/handlers/messaging.test.js new file mode 100644 index 00000000..76bc1bf0 --- /dev/null +++ b/test/commands/handlers/messaging.test.js @@ -0,0 +1,202 @@ +'use strict'; + +/* globals describe, it */ +const chai = require('chai'); +const expect = chai.expect; +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +const messaging = require('../../../src/commands/handlers/messaging'); +const IrcCommand = require('../../../src/commands/command'); + +chai.use(sinonChai); + +function makeHandler(opts) { + opts = opts || {}; + const ownNick = opts.ownNick || 'testnick'; + const enabledCaps = opts.enabledCaps || []; + + const handlers = {}; + messaging({ + addHandler: function(command, handler) { + handlers[command] = handler; + } + }); + + const spies = { + emit: sinon.stub(), + connection: { + write: sinon.stub(), + options: { version: null } + }, + network: { + addServerTimeOffset: sinon.stub(), + extractTargetGroup: sinon.stub().returns(null), + cap: { + isEnabled: function(cap) { + return enabledCaps.indexOf(cap) > -1; + } + } + }, + client: { + user: { nick: ownNick }, + caseCompare: function(a, b) { + return a.toLowerCase() === b.toLowerCase(); + } + } + }; + + return { handlers, spies }; +} + +describe('src/commands/handlers/messaging.js', function() { + describe('PRIVMSG self flag', function() { + it('sets self=false when echo-message cap is not active', function() { + const { handlers, spies } = makeHandler({ ownNick: 'testnick', enabledCaps: [] }); + const cmd = new IrcCommand('PRIVMSG', { + nick: 'testnick', + ident: 'user', + hostname: 'host', + params: ['#channel', 'hello world'], + tags: {} + }); + handlers.PRIVMSG(cmd, spies); + expect(spies.emit).to.have.been.calledWith('privmsg', sinon.match({ self: false })); + }); + + it('sets self=true when echo-message cap is active and nick matches', function() { + const { handlers, spies } = makeHandler({ ownNick: 'testnick', enabledCaps: ['echo-message'] }); + const cmd = new IrcCommand('PRIVMSG', { + nick: 'testnick', + ident: 'user', + hostname: 'host', + params: ['#channel', 'hello world'], + tags: {} + }); + handlers.PRIVMSG(cmd, spies); + expect(spies.emit).to.have.been.calledWith('privmsg', sinon.match({ self: true })); + }); + + it('sets self=false when echo-message is active but nick differs', function() { + const { handlers, spies } = makeHandler({ ownNick: 'testnick', enabledCaps: ['echo-message'] }); + const cmd = new IrcCommand('PRIVMSG', { + nick: 'othernick', + ident: 'user', + hostname: 'host', + params: ['#channel', 'hello world'], + tags: {} + }); + handlers.PRIVMSG(cmd, spies); + expect(spies.emit).to.have.been.calledWith('privmsg', sinon.match({ self: false })); + }); + + it('sets self=true when znc.in/self-message cap is active and nick matches', function() { + const { handlers, spies } = makeHandler({ ownNick: 'testnick', enabledCaps: ['znc.in/self-message'] }); + const cmd = new IrcCommand('PRIVMSG', { + nick: 'testnick', + ident: 'user', + hostname: 'host', + params: ['#channel', 'hello from znc'], + tags: {} + }); + handlers.PRIVMSG(cmd, spies); + expect(spies.emit).to.have.been.calledWith('privmsg', sinon.match({ self: true })); + }); + + it('is case-insensitive for nick comparison', function() { + const { handlers, spies } = makeHandler({ ownNick: 'TestNick', enabledCaps: ['echo-message'] }); + const cmd = new IrcCommand('PRIVMSG', { + nick: 'testnick', + ident: 'user', + hostname: 'host', + params: ['#channel', 'hello'], + tags: {} + }); + handlers.PRIVMSG(cmd, spies); + expect(spies.emit).to.have.been.calledWith('privmsg', sinon.match({ self: true })); + }); + }); + + describe('NOTICE self flag', function() { + it('sets self=false when echo-message cap is not active', function() { + const { handlers, spies } = makeHandler({ ownNick: 'testnick', enabledCaps: [] }); + const cmd = new IrcCommand('NOTICE', { + nick: 'testnick', + ident: 'user', + hostname: 'host', + params: ['#channel', 'a notice'], + tags: {} + }); + handlers.NOTICE(cmd, spies); + expect(spies.emit).to.have.been.calledWith('notice', sinon.match({ self: false })); + }); + + it('sets self=true when echo-message cap is active and nick matches', function() { + const { handlers, spies } = makeHandler({ ownNick: 'testnick', enabledCaps: ['echo-message'] }); + const cmd = new IrcCommand('NOTICE', { + nick: 'testnick', + ident: 'user', + hostname: 'host', + params: ['#channel', 'a notice'], + tags: {} + }); + handlers.NOTICE(cmd, spies); + expect(spies.emit).to.have.been.calledWith('notice', sinon.match({ self: true })); + }); + }); + + describe('TAGMSG self flag', function() { + it('sets self=false when echo-message cap is not active', function() { + const { handlers, spies } = makeHandler({ ownNick: 'testnick', enabledCaps: [] }); + const cmd = new IrcCommand('TAGMSG', { + nick: 'testnick', + ident: 'user', + hostname: 'host', + params: ['#channel'], + tags: { '+example': 'value' } + }); + handlers.TAGMSG(cmd, spies); + expect(spies.emit).to.have.been.calledWith('tagmsg', sinon.match({ self: false })); + }); + + it('sets self=true when echo-message cap is active and nick matches', function() { + const { handlers, spies } = makeHandler({ ownNick: 'testnick', enabledCaps: ['echo-message'] }); + const cmd = new IrcCommand('TAGMSG', { + nick: 'testnick', + ident: 'user', + hostname: 'host', + params: ['#channel'], + tags: { '+example': 'value' } + }); + handlers.TAGMSG(cmd, spies); + expect(spies.emit).to.have.been.calledWith('tagmsg', sinon.match({ self: true })); + }); + }); + + describe('ACTION (CTCP) self flag', function() { + it('sets self=false when echo-message cap is not active', function() { + const { handlers, spies } = makeHandler({ ownNick: 'testnick', enabledCaps: [] }); + const cmd = new IrcCommand('PRIVMSG', { + nick: 'testnick', + ident: 'user', + hostname: 'host', + params: ['#channel', '\x01ACTION waves\x01'], + tags: {} + }); + handlers.PRIVMSG(cmd, spies); + expect(spies.emit).to.have.been.calledWith('action', sinon.match({ self: false })); + }); + + it('sets self=true when echo-message cap is active and nick matches', function() { + const { handlers, spies } = makeHandler({ ownNick: 'testnick', enabledCaps: ['echo-message'] }); + const cmd = new IrcCommand('PRIVMSG', { + nick: 'testnick', + ident: 'user', + hostname: 'host', + params: ['#channel', '\x01ACTION waves\x01'], + tags: {} + }); + handlers.PRIVMSG(cmd, spies); + expect(spies.emit).to.have.been.calledWith('action', sinon.match({ self: true })); + }); + }); +});