UNPKG

eyearesee-client

Version:
927 lines (778 loc) 22.5 kB
'use strict' const EE = require('events') const Channel = require('./channel') const Message = require('./message') const Settings = require('./settings') const Socket = require('./socket') const utils = require('./utils') const debug = require('debug')('eyearesee:connection') const auth = require('./auth') const defaultSettings = new Map([ ['connect.auto', false] , ['log.events', true] , ['transcripts.enabled', false] , ['transcripts.location', null] , ['part.message', 'eyearesee https://github.com/evanlucas/eyearesee'] , ['persist.password', false] , ['messages.limit', 300] , ['channel.join.auto', false] ]) function assertOpts(opts) { if (!opts.name) { throw new TypeError('opts.name is required') } if (!opts.user || typeof opts.user !== 'object') { throw new TypeError('opts.user is required and must be an object') } if (!opts.user.nickname || typeof opts.user.nickname !== 'string') { throw new TypeError('opts.user.nickname is required and must be a string') } if (!opts.server || typeof opts.server !== 'object') { throw new TypeError('opts.server is required and must be an object') } if (!opts.server.host || typeof opts.server.host !== 'string') { throw new TypeError('opts.server.host is required and must be a string') } } module.exports = class Connection extends EE { constructor(opts) { super() opts = opts || {} assertOpts(opts) this.name = opts.name this.url = `/connections/${this.name}` this.user = opts.user this.server = opts.server this.nick = opts.user.nickname debug('connection', opts) // We keep the channels and queries separate so we can show them // in separate sections of the sidebar this.channels = new Map() this.queries = new Map() // TODO(evanlucas) Keep track of registration state // If a user tries to join a channel that requires registration, // try to show that. // TODO(evanlucas) change this to irc-connection // TODO(evanlucas) get rid of this? This is becoming a implementation // detail that should not be leaked from the electron app this.ele = '.logs-container' // The connection's settings this.settings = new Settings(defaultSettings, this) if (opts.settings && typeof opts.settings === 'object') { this.settings.load(opts.settings) } // Holds all messages for the connection. These are connection specific // and do not include channel messages. They are more infomational messages this.logs = [] // Keep a map of all channels and queries in order. // This is done so we can go to the next/previous channel or query this._panels = new Map() this.messageFormatter = opts.messageFormatter /* istanbul ignore next */ if (this.user.password && this.settings.get('persist.password')) { if (this.user.username) { auth.saveCreds(this.name, this.user.username, this.user.password) } } this.socket = new Socket({ secure: opts.secure === true , port: opts.server.port , host: opts.server.host , password: opts.user.password , nickname: opts.user.nickname , username: opts.user.username , realname: opts.user.realname , altNick: opts.user.altNick }) if (opts.channels && opts.channels.length) { this._addChannels(opts.channels) } if (opts.queries && opts.queries.length) { this._addQueries(opts.queries) } // Helps us determine if we should reconnect this._needsReconnect = true this._setup() } _addChannels(chans) { for (var i = 0; i < chans.length; i++) { const chan = chans[i] if (chan.type === 'channel') { this.addChannel(chan) } } } _addQueries(qs) { for (var i = 0; i < qs.length; i++) { const q = qs[i] if (q.type === 'private') { this.addQuery(q) } } } getConnection() { return this } _setup() { debug('setup') this._addMOTDHandlers() this._addStartupHandlers() this._addWhoisHandlers() this._addAwayHandlers() this._addChannelHandlers() const errHandler = (err) => { this.log({ type: 'error' , from: '' , message: err.message , ts: new Date() , channel: null }) } this.socket.on('error', errHandler) this.socket.on('IRC_ERROR', errHandler) this.socket.on('NOTICE', (msg) => { debug('NOTICE', msg) const hostmask = utils.hostmask(msg) const from = hostmask.nick if (from && from.toLowerCase() === 'nickserv') { const chan = this.addQuery({ name: 'NickServ' , topic: 'Conversation with NickServ' }) chan.addMessage({ message: msg.trailing , type: 'notice' , to: this.user.nickname , from: 'NickServ' }) } else if (msg.params.length) { const channel = msg.params[0].toLowerCase() const chan = this.channels.get(channel) if (chan) { chan.addMessage({ type: 'notice' , from: from , message: msg.trailing , ts: new Date() , to: null , hostmask: hostmask }) } } this.log({ type: 'notice' , from: from , message: msg.trailing , ts: new Date() , channel: null }) }) /* istanbul ignore next */ if (this.settings.get('connect.auto')) { debug('autoJoin enabled, connecting') this.connect() } } _maybeAutoJoin() { debug('maybe auto join') if (this.settings.get('channel.join.auto')) { setTimeout(() => { for (const chan of this.channels.keys()) { debug('auto join %s', chan) this.joinChannel(chan) } }, 5000) } } connect() { if (!this.connected) this.socket.connect() this.socket.once('connect', () => { this.connected = true }) this.socket.once('close', () => { debug('socket closed', this._needsReconnect) for (const chan of this.channels.values()) { if (chan.joined) chan.joined = false } this.connected = false if (this._needsReconnect) { debug('reconnecting...') this.log({ type: 'info' , message: 'Disconnected' , from: '' , ts: new Date() , channel: null }) this.connect() } }) } disconnect() { debug('disconnect') this._needsReconnect = false this.socket.removeAllListeners('close') this.socket.once('close', () => { debug('got close event') this.connected = false this.log({ type: 'info' , message: 'Disconnected' , from: '' , ts: new Date() , channel: null }) }) if (this.connected) { this.socket.close() } } _addStartupHandlers() { const log = (msg) => { let m = msg.trailing if (msg.command === 'RPL_WELCOME') { if (this.nick !== msg.params[0]) { this.updateMyNick(msg.params[0]) } this._maybeAutoJoin() } else if (msg.command === '396') { m = `${msg.params[1]} ${msg.trailing}` } this.log({ type: 'info' , from: '' , message: m , ts: new Date() , channel: null }) } this.socket.on('RPL_WELCOME', log) this.socket.on('RPL_YOURHOST', log) this.socket.on('RPL_CREATED', log) this.socket.on('RPL_LUSERCLIENT', log) this.socket.on('RPL_LUSERME', log) this.socket.on('RPL_LOCALUSERS', log) this.socket.on('RPL_GLOBALUSERS', log) this.socket.on('RPL_STATSDLINE', log) this.socket.on('396', log) this.socket.on('timeout', (t) => { this.log({ type: 'info' , from: '' , message: 'Connection timed out.' , ts: new Date() , channel: null }) }) this.socket.on('PING', (msg) => { this.write(`PONG :${msg.trailing}`) }) } _addMOTDHandlers() { const log = (msg) => { this.log({ type: 'motd' , from: '' , message: msg.trailing , ts: new Date() , channel: null }) } this.socket.on('RPL_MOTDSTART', log) this.socket.on('RPL_MOTD', log) this.socket.on('RPL_ENDOFMOTD', log) } _addWhoisHandlers() { // Taken from slate-irc // https://github.com/slate/slate-irc/blob/master/lib/plugins/whois.js // TODO(evanlucas) move to WeakMap const map = {} this.socket.on('RPL_WHOISUSER', (msg) => { const target = msg.params[1].toLowerCase() if (!map[target]) { map[target] = { nickname: msg.params[1] , username: msg.params[2] , hostname: msg.params[3] , realname: msg.trailing , channels: [] , oper: false , server: null } } else { map[target].nickname = msg.params[1] map[target].username = msg.params[2] map[target].hostname = msg.params[3] map[target].realname = msg.trailing map[target].channels = [] map[target].oper = false } }) this.socket.on('RPL_WHOISCHANNELS', (msg) => { const target = msg.params[1].toLowerCase() const channels = msg.trailing.split(' ') map[target].channels = map[target].channels.concat(channels) }) this.socket.on('RPL_WHOISSERVER', (msg) => { const target = msg.params[1].toLowerCase() map[target].server = msg.params[2] }) this.socket.on('RPL_AWAY', (msg) => { const target = msg.params[1].toLowerCase() if (!map[target]) return map[target].away = msg.trailing }) this.socket.on('RPL_WHOISOPERATOR', (msg) => { const target = msg.params[1].toLowerCase() map[target].oper = true }) this.socket.on('RPL_WHOISIDLE', (msg) => { const target = msg.params[1].toLowerCase() map[target].idle = msg.params[2] map[target].sign = msg.params[3] }) this.socket.on('RPL_ENDOFWHOIS', (msg) => { const target = msg.params[1].toLowerCase() if (!map[target]) return this.emit('whois', map[target]) }) } _addAwayHandlers() { const log = (msg) => { const type = msg.command === 'RPL_UNAWAY' ? 'unaway' : 'away' this.log({ type: 'info' , from: '' , message: `${type}: ${msg.trailing}` , ts: new Date() , channel: null }) } this.socket.on('RPL_UNAWAY', log) this.socket.on('RPL_NOWAWAY', log) } _addChannelHandlers() { this.socket.on('RPL_CHANNELMODEIS', (msg) => { const mode = msg.params[2] const channel = msg.params[1] const chan = this.channels.get(channel) if (!chan) { debug('RPL_CHANNELMODEIS cannot find channel %s', channel) return } chan.setMode(mode) }) this.socket.on('RPL_WHOREPLY', (msg) => { debug('RPL_WHOREPLY', msg) const channel = msg.params[1].toLowerCase() const nick = msg.params[5] const u = msg.params[2].toLowerCase() const realname = msg.trailing.split(' ') realname.shift() const opts = { nickname: nick , username: u , address: msg.params[3] , realname: realname.join(' ') , mode: (msg.params[6] || '').replace(/H|G/, '') , hostmask: { nick: nick , username: u , hostname: msg.params[3] , string: msg.prefix } } const chan = this.channels.get(channel) if (!chan) { debug('RPL_WHOREPLY cannot find channel %s', channel) return } setImmediate(() => { chan.addOrUpdateUser(opts) }) }) this.socket.on('RPL_ENDOFWHO', (msg) => { const channel = msg.params[msg.params.length - 1].toLowerCase() const chan = this.channels.get(channel) if (!chan) { return } setImmediate(() => { chan.update() }) }) this.socket.on('RPL_TOPIC_WHO_TIME', (msg) => { const hostmask = msg.params[2] const nick = hostmask.split('!').shift() || 'Unknown' const date = new Date(+(msg.params[3] + '000')) const channel = msg.params[1].toLowerCase() const chan = this.channels.get(channel) if (!chan) { debug('RPL_TOPIC_WHO_TIME cannot find channel %s', channel) return } chan.setTopicChanged(nick, date.toGMTString()) }) const topicHandler = (msg) => { const channel = msg.command === 'TOPIC' ? msg.params[0].toLowerCase() : msg.params[1].toLowerCase() const topic = msg.trailing const hostmask = utils.hostmask(msg) const nick = hostmask.nick const chan = this.channels.get(channel) if (!chan) { debug('TOPIC cannot find channel %s', channel) return } if (msg.command === 'RPL_TOPIC') chan.setTopic(topic) else chan.setTopic(topic, nick) this.write(`MODE ${chan.name}`) } this.socket.on('TOPIC', topicHandler) this.socket.on('RPL_TOPIC', topicHandler) this.socket.on('QUIT', (msg) => { const hostmask = utils.hostmask(msg) const nick = hostmask.nick const message = msg.trailing if (!nick) { return } if (nick === this.nick) { debug('I QUIT', message) } else { for (const chan of this.channels.values()) { chan.removeUser(nick, message) } } }) this.socket.on('PART', (msg) => { const hostmask = utils.hostmask(msg) const nick = hostmask.nick if (!nick) { return } const lower = nick.toLowerCase() debug('part %s %s', nick, this,nick) const isME = lower === (this.nick || '').toLowerCase() for (let i = 0; i < msg.params.length; i++) { const channel = msg.params[i].toLowerCase() const chan = this.channels.get(channel) if (chan) { if (isME) chan.joined = false chan.removeUser(nick, msg.trailing) } } }) this.socket.on('JOIN', (msg) => { // TODO(evanlucas) Send a WHO ${nick} on join if we don't already have // the user's info const hostmask = utils.hostmask(msg) const nick = hostmask.nick const channel = ((msg.params.length ? msg.params[0] : msg.trailing) || '').toLowerCase() const chan = this.channels.get(channel) if (!chan) { if (nick === this.nick) { const chan = this.addChannel({ name: channel , topic: '' , nick: nick }, true) chan.joined = true } return } if (nick !== this.nick) { chan.userJoined(nick, hostmask) debug('%s joined %s', nick, channel) } else { chan.joined = true chan.update() } }) this.socket.on('RPL_ENDOFNAMES', (msg) => { debug('RPL_ENDOFNAMES', msg) const channel = msg.params[msg.params.length - 1].toLowerCase() const chan = this.channels.get(channel) if (!chan) { debug('RPL_ENDOFNAMES cannot find channel %s', channel) return } this.write(`WHO ${channel}`) }) this.socket.on('RPL_NAMREPLY', (msg) => { debug('RPL_NAMREPLY', msg) const channel = msg.params[msg.params.length - 1].toLowerCase() const chan = this.channels.get(channel) if (!chan) { debug('RPL_NAMREPLY cannot find channel %s', channel) return } const names = msg.trailing.split(' ') for (var i = 0; i < names.length; i++) { const u = names[i].split(/([~&@%+])/) const item = { nickname: u.pop() , mode: u.pop() || '' } chan._addUser(item) } chan.setNames() }) this.socket.on('MODE', (msg) => { const hostmask = utils.hostmask(msg) const nick = hostmask.nick const target = msg.params[0].toLowerCase() const mode = msg.params[1] || msg.trailing const client = msg.params[2] const chan = this.channels.get(target) if (!chan) { debug('MODE could not find channel %s', target) return } const cl = client ? ` ${client}` : '' chan.addMessage({ message: `${nick} sets mode ${mode}${cl}` , type: 'info' , to: null , from: null , hostmask: hostmask , mention: client === this.nick }) this.write(`NAMES ${chan.name} ${client}`) chan.setNames() }) this.socket.on('INVITE', (msg) => { const hostmask = utils.hostmask(msg) const from = hostmask.nick const to = msg.params[0].toLowerCase() const channel = msg.trailing this.emit('invite', { hostmask: hostmask , from: from , to: to , channel: channel }) }) this.socket.on('PRIVMSG', (msg) => { const hostmask = utils.hostmask(msg) debug('hostmask', hostmask) const from = hostmask.nick const to = msg.params[0] let message = msg.trailing const channel = to.toLowerCase() let chan if (this.channels.has(channel)) { chan = this.channels.get(channel) } else if (channel === this.nick) { chan = this.addQuery({ name: from , topic: `Conversation with ${from}` , nick: this.nick , unread: 0 , messages: [] }) } if (!chan) return let type = 'message' const sub = message.substring(0, 7) if (sub === '\u0001ACTION') { type = 'action' message = message.substring(7).replace('\u0001', '') } chan.unread++ const mention = !!~message.toLowerCase().indexOf(this.nick) chan.addMessage({ message: message , type: type , to: to , from: from , hostmask: hostmask , mention: mention }) }) this.socket.on('NICK', (msg) => { debug('NICK %s', this.nick, msg) const hostmask = utils.hostmask(msg) const nick = hostmask.nick const newNick = msg.trailing || msg.params[0] if (nick === this.nick) { this.updateMyNick(newNick) this.log({ type: 'info' , message: `You are now known as ${newNick}` , ts: new Date() , channel: null }) // TODO(evanlucas) Add the log to each channel } this.handleNickChanged({ from: nick , to: newNick , hostmask: hostmask }) }) } log(opts) { if (!opts.type) { throw new Error('message type is required') } const msg = new Message({ type: opts.type , from: opts.from , message: opts.message , ts: opts.ts || new Date() , channel: opts.channel }) this.logs.push(msg) this.emit('log', msg) } send(target, data) { this.write(`PRIVMSG ${utils.getTarget(target)} :${data}`) } _updatePanels() { this._panels.clear() for (const chan of this.channels) { this._panels.set(chan[0], chan[1]) } for (const chan of this.queries) { this._panels.set(chan[0], chan[1]) } } // Channel stuff addChannel(opts) { const name = opts.name.toLowerCase() if (this.channels.has(name)) { return this.channels.get(name) } const chan = new Channel({ name: opts.name , topic: opts.topic , nick: opts.nick || this.user.nickname , messages: opts.messages || [] , unread: opts.unread || 0 , connection: this , type: 'channel' }) this.channels.set(name, chan) this._updatePanels() this.emit('channelAdded', chan) return chan } removeChannel(name) { const n = name.toLowerCase() if (!this.channels.has(n)) { return } const chan = this.channels.get(n) this.channels.delete(n) this._updatePanels() this.emit('channelRemoved', chan) } addQuery(opts) { debug('addQuery', opts.name) const name = opts.name.toLowerCase() if (this.queries.has(name)) { return this.queries.get(name) } const chan = new Channel({ name: opts.name , topic: opts.topic , nick: opts.nick || this.nick , messages: opts.messages || [] , unread: opts.unread || 0 , connection: this , from: opts.name , type: 'private' }) this.queries.set(name, chan) this._updatePanels() this.emit('queryAdded', chan) return chan } removeQuery(name) { const n = name.toLowerCase() if (!this.queries.has(n)) { return } const chan = this.queries.get(n) this.queries.delete(n) this._updatePanels() this.emit('queryRemoved', chan) } updateMyNick(nick) { debug('update my nick %s', nick) this.nick = nick for (const chan of this.channels.values()) { chan.updateMyNick(nick) } for (const chan of this.queries.values()) { chan.updateMyNick(nick) } } handleNickChanged(opts) { debug('nick changed', opts) const from = opts.from.toLowerCase() const to = opts.to const toLower = to.toLowerCase() for (const chan of this.channels.values()) { if (chan.users.has(from)) { const user = chan.users.get(from) user.nickname = to chan.users.delete(from) chan.users.set(toLower, user) chan.setNames() } } if (this.queries.has(from)) { const chan = this.queries.get(from) chan.from = to if (chan.users.has(from)) { const user = chan.users.get(from) user.nickname = to chan.users.delete(from) chan.users.set(toLower, user) chan.setNames() } this.queries.delete(from) this.queries.set(toLower, chan) } } // Helpers joinChannel(name) { this.socket.write(`JOIN ${name}`) } write(str) { this.socket.write(str) } toJSON() { const out = { name: this.name , server: this.server , user: { username: this.user.username , realname: this.user.realname , nickname: this.user.nickname , altNick: this.user.altNick } , channels: [] , queries: [] , settings: this.settings.toJSON() } for (const chan of this.channels.values()) { out.channels.push(chan.toJSON()) } for (const chan of this.queries.values()) { out.queries.push(chan.toJSON()) } return out } }