diff --git a/lib/irc/connection.js b/lib/irc/connection.js index 03fabf9..e43758e 100644 --- a/lib/irc/connection.js +++ b/lib/irc/connection.js @@ -200,11 +200,13 @@ class IRCConnection extends EventEmitter { let clear = () => cursor.close(() => { - if (this.dofetch) { - return setImmediate(() => this.fetchMessages(true)); - } else { - this.fetching = false; - } + db.redis.hset('irclast', this.session.auth.id.toString(), this.lastFetchedItem.toString(), () => { + if (this.dofetch) { + return setImmediate(() => this.fetchMessages(true)); + } else { + this.fetching = false; + } + }); }); let processNext = () => { @@ -231,7 +233,13 @@ class IRCConnection extends EventEmitter { return setImmediate(processNext); } - this.send({ source: message.nick, verb: 'PRIVMSG', target: message.channel ? message.channel.name : false, message: message.message }); + this.send({ + time: message.time, + source: message.nick, + verb: 'PRIVMSG', + target: message.channel ? message.channel.name : false, + message: message.message + }); setImmediate(processNext); }); }; @@ -245,19 +253,75 @@ class IRCConnection extends EventEmitter { } if (payload && typeof payload === 'object') { - payload.source = payload.source || this.hostname; + let message = []; - let message = [':' + payload.source]; + let verb = (payload.verb || '') + .toString() + .toUpperCase() + .trim(); - if (payload.verb) { - let cmd = (payload.verb || '') - .toString() - .toUpperCase() - .trim(); - if (codes.has(cmd)) { - cmd = codes.get(cmd); + let tags = payload.tags; + if (tags && !Array.isArray(tags) && typeof tags === 'object') { + tags = Object.keys(tags || {}).forEach(key => ({ + key, + value: tags[key] + })); + } + tags = [].concat(tags || []); + + if (['PRIVMSG', 'NOTICE'].includes(verb.toUpperCase())) { + let time = payload.time ? (typeof payload.time !== 'object' ? new Date(payload.time) : payload.time) : new Date(); + + if (this.capEnabled.has('server-time')) { + tags.push({ + key: 'time', + value: time.getISOString() + }); + } else if (this.capEnabled.has('znc.in/server-time')) { + tags.push({ + key: 't', + value: Math.round(time.getTime() / 1000) + }); } - message.push(cmd); + } + + if (tags.length) { + let tagStr = tags + .map(tag => { + if (typeof tag.value === 'boolean') { + if (tag.value === true) { + return tag.key; + } + return; + } + return ( + tag.key + + '=' + + (tag.value || '').toString().replace(/[;\r\n\\ ]/g, c => { + switch (c) { + case ';': + return '\\:'; + case '\r': + return '\\r'; + case '\n': + return '\\n'; + case ' ': + return '\\s'; + } + }) + ); + }) + .join(';'); + if (tagStr.length) { + message.push('@' + tagStr); + } + } + + payload.source = payload.source || this.hostname; + message.push(':' + payload.source); + + if (verb) { + message.push(codes.has(verb) ? codes.get(verb) : verb); } if (payload.target) { @@ -424,14 +488,49 @@ class IRCConnection extends EventEmitter { return this.processQueue(); } - let match = line.match(/^\s*(?::[^\s]+\s+)?([^\s]+)\s*/); + let match = line.match(/^\s*(?:@([^\s]+)\s+)?(?::([^\s]+)\s+)?([^\s]+)\s*/); if (!match) { // TODO: send error message // Can it even happen? return this.processQueue(); } - let verb = (match[1] || '').toString().toUpperCase(); + let tags = !match[1] ? false : new Map(); + (match[1] || '') + .toString() + .split(';') + .forEach(elm => { + if (!elm) { + return; + } + let eqPos = elm.indexOf('='); + if (eqPos < 0) { + tags.set(elm, true); + return; + } + + let key = elm.substr(0, eqPos); + let value = elm.substr(eqPos + 1).replace(/\\(.)/g, (m, c) => { + switch (c) { + case ':': + return ';'; + case 's': + return ' '; + case '\\': + return '\\'; + case 'r': + return '\r'; + case 'n': + return '\n'; + default: + return c; + } + }); + tags.set(key, value); + }); + + let prefix = (match[3] || '').toString() || false; + let verb = (match[3] || '').toString().toUpperCase(); let params = line.substr(match[0].length); let data; let separatorPos = params.indexOf(' :'); @@ -460,7 +559,7 @@ class IRCConnection extends EventEmitter { ); if (typeof this['command_' + verb] === 'function') { - this['command_' + verb](params, () => { + this['command_' + verb](tags, prefix, params, () => { this.processQueue(); }); } else { @@ -476,6 +575,59 @@ class IRCConnection extends EventEmitter { } } + printNickList(channelData, fresh, next) { + db.database + .collection('nicks') + .find({ + user: { $in: channelData.members } + }) + .project({ + _id: true, + nick: true, + user: true + }) + .toArray((err, nickList) => { + if (err) { + this.send({ verb: 'ERR_FILEERROR', params: channelData.channel, message: err.message }); + return next(); + } + + if (!nickList) { + nickList = []; + } + + if (fresh) { + nickList.unshift({ + nick: this.session.nick + }); + } + + let lines = []; + let curLine = { members: [], length: 0 }; + nickList.forEach(nickData => { + curLine.members.push(nickData.nick); + curLine.length += nickData.nick.length + 1; + if (curLine.length > 400) { + lines.push(curLine); + curLine = { members: [], length: 0 }; + } + }); + if (curLine.length) { + lines.push(curLine); + } + + this.send({ source: this.getFormattedName(), verb: 'JOIN', target: false, message: channelData.channel }); + + lines.forEach(line => { + this.send({ verb: 'RPL_NAMREPLY', params: ['=', channelData.channel], message: line.members.join(' ') }); + }); + + this.send({ verb: 'RPL_ENDOFNAMES', params: channelData.channel, message: 'End of /NAMES list' }); + + next(); + }); + } + getFormattedName(skipNick, nick) { nick = nick || this.session.nick; return (!skipNick && nick ? nick + '!' : '') + (this.session.user || 'unknown') + '@' + this.session.clientHostname; @@ -530,12 +682,71 @@ class IRCConnection extends EventEmitter { verb: 'NOTICE', message: 'This server requires all users to be authenticated. Identify via /msg NickServ identify ' }); + } else { + this.initializeSubscriptions(); } this.starting = false; this.started = true; } + initializeSubscriptions(next) { + next = next || (() => false); + db.database + .collection('channels') + .find({ + members: this.session.auth.id + }) + .project({ + _id: true, + channel: true, + members: true + }) + .toArray((err, channels) => { + if (err) { + this.server.logger.error( + { + err, + tnx: 'setup', + cid: this.id, + user: this.session.auth.id + }, + 'Failed loading channels. %s', + err.message + ); + return next(); + } + if (Array.isArray(channels)) { + channels.forEach(channelData => { + this.server.logger.info( + { + tnx: 'setup', + cid: this.id, + channel: channelData._id + }, + 'Joining %s to channel %s', + this.session.auth.id, + channelData._id + ); + + this.subscribe([this.session.ns, '#', channelData._id].join('.')); + this.printNickList(channelData, false, next); + //this.send({ source: this.getFormattedName(), verb: 'JOIN', target: false, message: channelData.channel }); + }); + } + + // private messages + this.subscribe([this.session.ns, '%', this.session.auth.id].join('.')); + + // general notifications + this.subscribe([this.session.ns, '!', '*'].join('.')); + + this.fetchMessages(); + + return next(); + }); + } + authenticate(user, password, next) { this.server.userHandler.authenticate( user.replace(/\+/, '@'), @@ -575,60 +786,37 @@ class IRCConnection extends EventEmitter { } let ns = userData.ns; - - if (userData.address) { - let parts = userData.address.split('@'); - this.session.user = parts.shift(); - this.session.clientHostname = parts.join('@'); - if (!ns) { - ns = this.session.clientHostname; + db.redis.hget('irclast', userData._id.toString(), (err, ircLast) => { + if (err) { + // ignore } - } - this.session.nick = this.session.nick || userData.username; - this.session.ns = ns || 'root'; - - db.database - .collection('channels') - .find({ - members: userData._id - }) - .project({ - _id: true, - channel: true - }) - .toArray((err, channels) => { - if (err) { - return next(err); + if (ircLast) { + let lastFetchedItem = new ObjectID(ircLast); + let maxAge = Math.round(Date.now() / 1000 - 2 * 24 * 3600); + if (lastFetchedItem.getTimestamp().getTime() < maxAge * 1000) { + lastFetchedItem = new ObjectID(maxAge); } - if (Array.isArray(channels)) { - channels.forEach(channelData => { - this.server.logger.info( - { - tnx: 'setup', - cid: this.id, - channel: channelData._id - }, - 'Joining %s to channel %s', - userData._id, - channelData._id - ); - this.subscribe([this.session.ns, '#', channelData._id].join('.')); - this.send({ source: this.getFormattedName(), verb: 'JOIN', target: false, message: channelData.channel }); - }); + this.lastFetchedItem = lastFetchedItem; + } + + if (userData.address) { + let parts = userData.address.split('@'); + this.session.user = parts.shift(); + this.session.clientHostname = parts.join('@'); + if (!ns) { + ns = this.session.clientHostname; } + } - // private messages - this.subscribe([this.session.ns, '%', userData._id].join('.')); + this.session.nick = this.session.nick || userData.username; + this.session.ns = ns || 'root'; - // general notifications - this.subscribe([this.session.ns, '!', '*'].join('.')); - - next(null, { - id: userData._id, - username: userData.username - }); + next(null, { + id: userData._id, + username: userData.username }); + }); }); } ); @@ -796,7 +984,7 @@ class IRCConnection extends EventEmitter { this.close(); } - command_PING(params, next) { + command_PING(tags, prefix, params, next) { if (!params.length) { this.send({ verb: 'ERR_NEEDMOREPARAMS', params: 'PING', message: 'Not enough parameters' }); return next(); @@ -810,11 +998,11 @@ class IRCConnection extends EventEmitter { return next(); } - command_PONG(params, next) { + command_PONG(tags, prefix, params, next) { return next(); } - command_NICK(params, next) { + command_NICK(tags, prefix, params, next) { let currentSource = this.getFormattedName(); if (params.length > 1) { @@ -839,7 +1027,7 @@ class IRCConnection extends EventEmitter { } } - command_PASS(params, next) { + command_PASS(tags, prefix, params, next) { if (!params.length) { this.send({ verb: 'ERR_NEEDMOREPARAMS', params: 'PASS', message: 'Not enough parameters' }); return next(); @@ -852,7 +1040,7 @@ class IRCConnection extends EventEmitter { return next(); } - command_USER(params, next) { + command_USER(tags, prefix, params, next) { if (this.session.user) { this.send({ verb: 'ERR_ALREADYREGISTERED', message: 'You may not reregister' }); return next(); @@ -884,7 +1072,7 @@ class IRCConnection extends EventEmitter { } } - command_JOIN(params, next) { + command_JOIN(tags, prefix, params, next) { if (!this.session.user || !this.session.nick) { this.send({ verb: 'ERR_NOTREGISTERED', params: 'JOIN', message: 'You have not registered' }); return next(); @@ -925,57 +1113,7 @@ class IRCConnection extends EventEmitter { // notify other instances of self this.publish([this.session.ns, '%', idString].join('.'), eventData); - - db.database - .collection('nicks') - .find({ - user: { $in: channelData.members } - }) - .project({ - _id: true, - nick: true, - user: true - }) - .toArray((err, nickList) => { - if (err) { - this.send({ verb: 'ERR_FILEERROR', params: channelData.channel, message: err.message }); - return next(); - } - - if (!nickList) { - nickList = []; - } - - if (fresh) { - nickList.unshift({ - nick: this.session.nick - }); - } - - let lines = []; - let curLine = { members: [], length: 0 }; - nickList.forEach(nickData => { - curLine.members.push(nickData.nick); - curLine.length += nickData.nick.length + 1; - if (curLine.length > 400) { - lines.push(curLine); - curLine = { members: [], length: 0 }; - } - }); - if (curLine.length) { - lines.push(curLine); - } - - this.send({ source: this.getFormattedName(), verb: 'JOIN', target: false, message: channelData.channel }); - - lines.forEach(line => { - this.send({ verb: 'RPL_NAMREPLY', params: ['=', channelData.channel], message: line.members.join(' ') }); - }); - - this.send({ verb: 'RPL_ENDOFNAMES', params: channelData.channel, message: 'End of /NAMES list' }); - - next(); - }); + this.printNickList(channelData, fresh, next); }; let tryCount = 0; @@ -1056,7 +1194,7 @@ class IRCConnection extends EventEmitter { tryGetChannel(); } - command_PART(params, next) { + command_PART(tags, prefix, params, next) { if (!this.session.user || !this.session.nick) { this.send({ verb: 'ERR_NOTREGISTERED', params: 'JOIN', message: 'You have not registered' }); return next(); @@ -1119,7 +1257,7 @@ class IRCConnection extends EventEmitter { }); } - command_PRIVMSG(params, next) { + command_PRIVMSG(tags, prefix, params, next) { if (!this.session.user || !this.session.nick) { this.send({ verb: 'ERR_NOTREGISTERED', params: 'PRIVMSG', message: 'You have not registered' }); return next(); @@ -1138,6 +1276,8 @@ class IRCConnection extends EventEmitter { if (target.trim().toLowerCase() === 'nickserv') { return this.command_NICKSERV( + tags, + prefix, params .slice(1) .join(' ') @@ -1251,13 +1391,13 @@ class IRCConnection extends EventEmitter { }); } - command_CAP(params, next) { + command_CAP(tags, prefix, params, next) { if (!params.length) { this.send({ verb: 'ERR_NEEDMOREPARAMS', target: this.session.nick || '*', params: 'CAP', message: 'Not enough parameters' }); return next(); } - let allowed = ['sasl']; + let allowed = ['sasl', 'server-time', 'znc.in/server-time']; this.capStarted = true; let subcommand = params @@ -1323,7 +1463,7 @@ class IRCConnection extends EventEmitter { next(); } - command_NICKSERV(params, next) { + command_NICKSERV(tags, prefix, params, next) { if (!params.length) { this.send({ verb: 'ERR_NEEDMOREPARAMS', target: this.session.nick || '*', params: 'CAP', message: 'Not enough parameters' }); return next(); @@ -1360,7 +1500,7 @@ class IRCConnection extends EventEmitter { target: this.session.nick, message: 'You are now identified for ' + this.session.user }); - return this.verifyNickChange(false, next); + return this.verifyNickChange(false, () => this.initializeSubscriptions(next)); } else { this.send({ source: 'NickServ!NickServ@services.', @@ -1376,7 +1516,7 @@ class IRCConnection extends EventEmitter { next(); } - command_AUTHENTICATE(params, next) { + command_AUTHENTICATE(tags, prefix, params, next) { if (!params.length) { this.send({ verb: 'ERR_NEEDMOREPARAMS', target: this.session.nick || '*', params: 'AUTHENTICATE', message: 'Not enough parameters' }); return next();