diff --git a/README.md b/README.md index 9eaf0ec2..51c3ac8a 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ function ExampleMiddleware() { } } - if (command === 'message' && event.event.nick.toLowerCase() === 'nickserv') { + if (command === 'message' && client.caseCompare(event.event.nick, 'nickserv')) { // Handle success/retries/failures } diff --git a/docs/clientapi.md b/docs/clientapi.md index 77bcaa98..df090881 100644 --- a/docs/clientapi.md +++ b/docs/clientapi.md @@ -131,6 +131,15 @@ Create a channel object with the following methods: * `part([part_message])` * `join([key])` +##### `.caseCompare(string1, string2)` +Compare two strings using the networks casemapping setting. + +##### `.caseUpper(string)` +Uppercase the characters in string using the networks casemapping setting. + +##### `.caseLower(string)` +Lowercase the characters in string using the networks casemapping setting. + ##### `.match(match_regex, cb[, message_type])` Call `cb()` when any incoming message matches `match_regex`. diff --git a/examples/bot.js b/examples/bot.js index f1e697bb..a602fc63 100644 --- a/examples/bot.js +++ b/examples/bot.js @@ -17,7 +17,7 @@ function NickservMiddleware() { // eslint-disable-line } } - if (command === 'PRIVMSG' && event.params[0].toLowerCase() === 'nickserv') { + if (command === 'PRIVMSG' && client.caseCompare(event.params[0], 'nickserv')) { // Handle success/retries/failures } diff --git a/src/channel.js b/src/channel.js index cc5e1be1..0d635dc6 100644 --- a/src/channel.js +++ b/src/channel.js @@ -30,7 +30,7 @@ module.exports = class IrcChannel { this.users = []; irc_client.on('userlist', (event) => { - if (event.channel.toLowerCase() === this.name.toLowerCase()) { + if (irc_client.caseCompare(event.channel, this.name)) { this.users = event.users; } }); @@ -42,25 +42,25 @@ module.exports = class IrcChannel { irc_client.on('part', (event) => { if (event.channel === this.name) { this.users = _.filter(this.users, function(o) { - return o.nick.toLowerCase() !== event.nick.toLowerCase(); + return !irc_client.caseCompare(event.nick, o.nick); }); } }); irc_client.on('kick', (event) => { if (event.channel === this.name) { this.users = _.filter(this.users, function(o) { - return o.nick.toLowerCase() !== event.kicked.toLowerCase(); + return !irc_client.caseCompare(event.kicked, o.nick); }); } }); irc_client.on('quit', (event) => { this.users = _.filter(this.users, function(o) { - return o.nick.toLowerCase() !== event.nick.toLowerCase(); + return !irc_client.caseCompare(event.nick, o.nick); }); }); irc_client.on('nick', (event) => { _.find(this.users, function(o) { - if (o.nick.toLowerCase() === event.nick.toLowerCase()) { + if (irc_client.caseCompare(event.nick, o.nick)) { o.nick = event.new_nick; return true; } @@ -76,7 +76,7 @@ module.exports = class IrcChannel { } */ - if (event.target.toLowerCase() !== this.name.toLowerCase()) { + if (irc_client.caseCompare(event.target, this.name)) { return; } @@ -93,7 +93,7 @@ module.exports = class IrcChannel { } else { // It's a user mode // Find the user affected const user = _.find(this.users, u => - u.nick.toLowerCase() === mode.param.toLowerCase() + irc_client.caseCompare(u.nick, mode.param) ); if (!user) { @@ -175,7 +175,7 @@ module.exports = class IrcChannel { }); this.irc_client.on('privmsg', (event) => { - if (event.target.toLowerCase() === this.name.toLowerCase()) { + if (this.irc_client.caseCompare(event.target, this.name)) { read_queue.push(event); if (is_reading) { @@ -189,7 +189,7 @@ module.exports = class IrcChannel { updateUsers(cb) { const updateUserList = (event) => { - if (event.channel.toLowerCase() === this.name.toLowerCase()) { + if (this.irc_client.caseCompare(event.channel, this.name)) { this.irc_client.removeListener('userlist', updateUserList); if (typeof cb === 'function') { cb(this); } } diff --git a/src/client.js b/src/client.js index 2879d44a..f481f48c 100644 --- a/src/client.js +++ b/src/client.js @@ -127,13 +127,13 @@ module.exports = class IrcClient extends EventEmitter { }); client.on('away', function(event) { - if (event.nick.toLowerCase() === client.user.nick.toLowerCase()) { + if (client.caseCompare(event.nick, client.user.nick)) { client.user.away = true; } }); client.on('back', function(event) { - if (event.nick.toLowerCase() === client.user.nick.toLowerCase()) { + if (client.caseCompare(event.nick, client.user.nick)) { client.user.away = false; } }); @@ -461,7 +461,7 @@ module.exports = class IrcClient extends EventEmitter { } function onInviteList(event) { - if (event.channel.toLowerCase() === channel.toLowerCase()) { + if (client.caseCompare(event.channel, channel)) { unbindEvents(); if (typeof cb === 'function') { cb(event); @@ -524,7 +524,7 @@ module.exports = class IrcClient extends EventEmitter { const raw = ['MODE', channel, 'b']; this.on('banlist', function onBanlist(event) { - if (event.channel.toLowerCase() === channel.toLowerCase()) { + if (client.caseCompare(event.channel, channel)) { client.removeListener('banlist', onBanlist); if (typeof cb === 'function') { cb(event); @@ -611,7 +611,7 @@ module.exports = class IrcClient extends EventEmitter { }); this.on('whois', function onWhois(event) { - if (event.nick.toLowerCase() === target.toLowerCase()) { + if (client.caseCompare(event.nick, target)) { client.removeListener('whois', onWhois); if (typeof cb === 'function') { cb(event); @@ -637,7 +637,7 @@ module.exports = class IrcClient extends EventEmitter { }); this.on('whowas', function onWhowas(event) { - if (event.nick.toLowerCase() === target.toLowerCase()) { + if (client.caseCompare(event.nick, target)) { client.removeListener('whowas', onWhowas); if (typeof cb === 'function') { cb(event); @@ -755,4 +755,85 @@ module.exports = class IrcClient extends EventEmitter { matchAction(match_regex, cb) { return this.match(match_regex, cb, 'action'); } + + caseCompare(string1, string2) { + const length = string1.length; + + if (length !== string2.length) { + return false; + } + + const upperBound = this._getCaseMappingUpperAsciiBound(); + + for (let i = 0; i < length; i++) { + let charCode1 = string1.charCodeAt(i); + let charCode2 = string2.charCodeAt(i); + + if (charCode1 >= 65 && charCode1 <= upperBound) { + charCode1 += 32; + } + + if (charCode2 >= 65 && charCode2 <= upperBound) { + charCode2 += 32; + } + + if (charCode1 !== charCode2) { + return false; + } + } + + return true; + } + + caseLower(string) { + const upperBound = this._getCaseMappingUpperAsciiBound(); + let result = ''; + + for (let i = 0; i < string.length; i++) { + const charCode = string.charCodeAt(i); + + // ASCII character from 'A' to upper bound defined above + if (charCode >= 65 && charCode <= upperBound) { + // All the relevant uppercase characters are exactly + // 32 bytes apart from lowercase ones, so we simply add 32 + // and get the equivalent character in lower case + result += String.fromCharCode(charCode + 32); + } else { + result += string[i]; + } + } + + return result; + } + + caseUpper(string) { + const upperBound = this._getCaseMappingUpperAsciiBound() + 32; + let result = ''; + + for (let i = 0; i < string.length; i++) { + const charCode = string.charCodeAt(i); + + // ASCII character from 'a' to upper bound defined above + if (charCode >= 97 && charCode <= upperBound) { + // All the relevant lowercase characters are exactly + // 32 bytes apart from lowercase ones, so we simply subtract 32 + // and get the equivalent character in upper case + result += String.fromCharCode(charCode - 32); + } else { + result += string[i]; + } + } + + return result; + } + + _getCaseMappingUpperAsciiBound() { + if (this.network.options.CASEMAPPING === 'ascii') { + return 90; // 'Z' + } else if (this.network.options.CASEMAPPING === 'strict-rfc1459') { + return 93; // ']' + } + + return 94; // '^' - default casemapping=rfc1459 + } }; diff --git a/src/commands/handlers/registration.js b/src/commands/handlers/registration.js index 6285e1b3..00c8a12b 100644 --- a/src/commands/handlers/registration.js +++ b/src/commands/handlers/registration.js @@ -72,6 +72,8 @@ const handlers = { handler.network.options.STATUSMSG = handler.network.options.STATUSMSG.split(''); } else if (option[0] === 'CHANMODES') { handler.network.options.CHANMODES = option[1].split(','); + } else if (option[0] === 'CASEMAPPING') { + handler.network.options.CASEMAPPING = option[1]; } else if (option[0] === 'NETWORK') { handler.network.name = option[1]; } else if (option[0] === 'NAMESX' && !handler.network.cap.isEnabled('multi-prefix')) { diff --git a/src/networkinfo.js b/src/networkinfo.js index 968df92e..14bea74d 100644 --- a/src/networkinfo.js +++ b/src/networkinfo.js @@ -18,6 +18,7 @@ function NetworkInfo() { // Network provided options this.options = { + CASEMAPPING: 'rfc1459', PREFIX: [ { symbol: '~', mode: 'q' }, { symbol: '&', mode: 'a' }, diff --git a/test/casefolding.js b/test/casefolding.js new file mode 100644 index 00000000..e251186f --- /dev/null +++ b/test/casefolding.js @@ -0,0 +1,116 @@ +'use strict'; +/* globals describe, it */ +const chai = require('chai'); +const IrcClient = require('../src/client'); +const expect = chai.expect; + +chai.use(require('chai-subset')); + +describe('src/client.js', function() { + describe('caseLower', function() { + it('CASEMAPPING=rfc1459', function() { + const client = new IrcClient(); + + expect(client.network.options.CASEMAPPING).to.equal('rfc1459'); // default + expect(client.caseLower('ABCDEFGHIJKLMNOPQRSTUVWXYZ')).to.equal('abcdefghijklmnopqrstuvwxyz'); + expect(client.caseLower('ÀTEST[]^\\')).to.equal('Àtest{}~|'); + expect(client.caseLower('Àtest{}~|')).to.equal('Àtest{}~|'); + expect(client.caseLower('@?A_`#&')).to.equal('@?a_`#&'); + }); + + it('CASEMAPPING=strict-rfc1459', function() { + const client = new IrcClient(); + client.network.options.CASEMAPPING = 'strict-rfc1459'; + + expect(client.caseLower('ABCDEFGHIJKLMNOPQRSTUVWXYZ')).to.equal('abcdefghijklmnopqrstuvwxyz'); + expect(client.caseLower('ÀTEST[]^\\')).to.equal('Àtest{}^|'); + expect(client.caseLower('Àtest{}^|')).to.equal('Àtest{}^|'); + expect(client.caseLower('@?A^_`#&')).to.equal('@?a^_`#&'); + }); + + it('CASEMAPPING=ascii', function() { + const client = new IrcClient(); + client.network.options.CASEMAPPING = 'ascii'; + + expect(client.caseLower('ABCDEFGHIJKLMNOPQRSTUVWXYZ')).to.equal('abcdefghijklmnopqrstuvwxyz'); + expect(client.caseLower('ÀTEST[]^\\{}~|#&')).to.equal('Àtest[]^\\{}~|#&'); + expect(client.caseLower('ПРИВЕТ, как дела? 👋')).to.equal('ПРИВЕТ, как дела? 👋'); + }); + }); + + describe('caseUpper', function() { + it('CASEMAPPING=rfc1459', function() { + const client = new IrcClient(); + + expect(client.network.options.CASEMAPPING).to.equal('rfc1459'); // default + expect(client.caseUpper('abcdefghijklmnopqrstuvwxyz')).to.equal('ABCDEFGHIJKLMNOPQRSTUVWXYZ'); + expect(client.caseUpper('ÀTEST{}~|')).to.equal('ÀTEST[]^\\'); + expect(client.caseUpper('ÀTEST[]^\\')).to.equal('ÀTEST[]^\\'); + expect(client.caseUpper('@?a_`#&')).to.equal('@?A_`#&'); + }); + + it('CASEMAPPING=strict-rfc1459', function() { + const client = new IrcClient(); + client.network.options.CASEMAPPING = 'strict-rfc1459'; + + expect(client.caseUpper('abcdefghijklmnopqrstuvwxyz')).to.equal('ABCDEFGHIJKLMNOPQRSTUVWXYZ'); + expect(client.caseUpper('ÀTEST{}~|')).to.equal('ÀTEST[]~\\'); + expect(client.caseUpper('ÀTEST[]^\\')).to.equal('ÀTEST[]^\\'); + expect(client.caseUpper('@?a^~_`#&')).to.equal('@?A^~_`#&'); + }); + + it('CASEMAPPING=ascii', function() { + const client = new IrcClient(); + client.network.options.CASEMAPPING = 'ascii'; + + expect(client.caseUpper('abcdefghijklmnopqrstuvwxyz')).to.equal('ABCDEFGHIJKLMNOPQRSTUVWXYZ'); + expect(client.caseUpper('Àtest[]^\\{}~|#&')).to.equal('ÀTEST[]^\\{}~|#&'); + expect(client.caseUpper('ПРИВЕТ, как дела? 👋')).to.equal('ПРИВЕТ, как дела? 👋'); + }); + }); + + /* eslint-disable no-unused-expressions */ + describe('caseCompare', function() { + it('CASEMAPPING=rfc1459', function() { + const client = new IrcClient(); + + expect(client.network.options.CASEMAPPING).to.equal('rfc1459'); // default + + expect(client.caseCompare('abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')).to.be.true; + expect(client.caseCompare('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')).to.be.true; + expect(client.caseCompare('Àtest{}~|', 'ÀTEST[]^\\')).to.be.true; + expect(client.caseCompare('ÀTEST[]^\\', 'Àtest{}~|')).to.be.true; + expect(client.caseCompare('Àtest{}~|', 'Àtest{}~|')).to.be.true; + expect(client.caseCompare('@?A_`#&', '@?a_`#&')).to.be.true; + }); + + it('CASEMAPPING=strict-rfc1459', function() { + const client = new IrcClient(); + client.network.options.CASEMAPPING = 'strict-rfc1459'; + + expect(client.caseCompare('abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')).to.be.true; + expect(client.caseCompare('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')).to.be.true; + expect(client.caseCompare('Àtest{}^|', 'ÀTEST[]^\\')).to.be.true; + expect(client.caseCompare('ÀTEST[]^\\', 'Àtest{}^|')).to.be.true; + expect(client.caseCompare('Àtest{}^|', 'Àtest{}^|')).to.be.true; + expect(client.caseCompare('@?A^_`#&', '@?a^_`#&')).to.be.true; + }); + + it('CASEMAPPING=ascii', function() { + const client = new IrcClient(); + client.network.options.CASEMAPPING = 'ascii'; + + expect(client.caseCompare('abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')).to.be.true; + expect(client.caseCompare('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')).to.be.true; + expect(client.caseCompare('Àtest[]^\\{}~|#&', 'ÀTEST[]^\\{}~|#&')).to.be.true; + expect(client.caseCompare('ÀTEST[]^\\{}~|#&', 'Àtest[]^\\{}~|#&')).to.be.true; + expect(client.caseCompare('ПРИВЕТ, как дела? 👋', 'ПРИВЕТ, как дела? 👋')).to.be.true; + expect(client.caseCompare('#HELLO1', '#HELLO2')).to.be.false; + expect(client.caseCompare('#HELLO', '#HELLO2')).to.be.false; + expect(client.caseCompare('#HELLO', '#HELL')).to.be.false; + expect(client.caseCompare('#HELL', '#HELLO')).to.be.false; + expect(client.caseCompare('#HELLOZ', '#HELLOZ')).to.be.true; + expect(client.caseCompare('#HELLOZ[', '#HELLOZ{')).to.be.false; + }); + }); +});