diff --git a/WHATSNEW.md b/WHATSNEW.md index 38df6cf4..71e12a69 100644 --- a/WHATSNEW.md +++ b/WHATSNEW.md @@ -36,7 +36,7 @@ This document attempts to track **major** changes and additions in ENiGMA½. For * Performing a file scan/import using `oputil.js fb scan` now recognizes various `FILES.BBS` formats. * Usernames found in the `config.users.badUserNames` are now not only disallowed from applying, but disconnected at any login attempt. * Total minutes online is now tracked for users. Of course, it only starts after you get the update :) - +* Form entries in `menu.hjson` can now be omitted from submission handlers using `omit: true` ## 0.0.8-alpha * [Mystic BBS style](http://wiki.mysticbbs.com/doku.php?id=displaycodes) extended pipe color codes. These allow for example, to set "iCE" background colors. diff --git a/art/themes/luciano_blocktronics/MMENU.ANS b/art/themes/luciano_blocktronics/MMENU.ANS index bd8a483f..9db39ca9 100644 Binary files a/art/themes/luciano_blocktronics/MMENU.ANS and b/art/themes/luciano_blocktronics/MMENU.ANS differ diff --git a/art/themes/luciano_blocktronics/USERACHIEV.ANS b/art/themes/luciano_blocktronics/USERACHIEV.ans similarity index 100% rename from art/themes/luciano_blocktronics/USERACHIEV.ANS rename to art/themes/luciano_blocktronics/USERACHIEV.ans diff --git a/art/themes/luciano_blocktronics/mrc.ans b/art/themes/luciano_blocktronics/mrc.ans new file mode 100644 index 00000000..a3079f3c Binary files /dev/null and b/art/themes/luciano_blocktronics/mrc.ans differ diff --git a/art/themes/luciano_blocktronics/theme.hjson b/art/themes/luciano_blocktronics/theme.hjson index 998d4c34..0e21329c 100644 --- a/art/themes/luciano_blocktronics/theme.hjson +++ b/art/themes/luciano_blocktronics/theme.hjson @@ -1078,12 +1078,65 @@ } } - - //////////////////////////////// ERC /////////////////////////////// - - ercClient: { + mrc: { config: { - //chatEntryFormat: "|00|08[|03{bbsTag}|08] |10{userName}|08: |02{message}" + messageFormat: "|00|10<|02{fromUserName}|10>|00 |03{message}|00" + privateMessageFormat: "|00|10<|02{fromUserName}|15->{toUserName}|10>|00 |03{message}|00" + } + 0: { + mci: { + MT1: { + width: 72 + height: 19 + } + ET2: { + width: 69 // fnarr! + maxLength: 140 + } + TL3: { + width: 20 + } + TL4: { + width: 20 + } + TL5: { + width: 2 + } + TL6: { + width: 2 + } + } + } + } + + irc: { + config: { + messageFormat: "|00|10<|02{fromUserName}|10>|00 |03{message}|00" + privateMessageFormat: "|00|10<|02{fromUserName}|15->{toUserName}|10>|00 |03{message}|00" + } + 0: { + mci: { + MT1: { + width: 72 + height: 17 + } + ET2: { + width: 69 // fnarr! + maxLength: 140 + } + TL3: { + width: 20 + } + TL4: { + width: 20 + } + TL5: { + width: 2 + } + TL6: { + width: 2 + } + } } } } diff --git a/core/client.js b/core/client.js index 8820a3ac..b044bcdf 100644 --- a/core/client.js +++ b/core/client.js @@ -433,6 +433,11 @@ Client.prototype.setTermType = function(termType) { }; Client.prototype.startIdleMonitor = function() { + // clear existing, if any + if(this.idleCheck) { + this.stopIdleMonitor(); + } + this.lastKeyPressMs = Date.now(); // @@ -468,6 +473,9 @@ Client.prototype.startIdleMonitor = function() { idleLogoutSeconds = Config().users.preAuthIdleLogoutSeconds; } + // use override value if set + idleLogoutSeconds = this.idleLogoutSecondsOverride || idleLogoutSeconds; + if(nowMs - this.lastKeyPressMs >= (idleLogoutSeconds * 1000)) { this.emit('idle timeout'); } @@ -475,7 +483,18 @@ Client.prototype.startIdleMonitor = function() { }; Client.prototype.stopIdleMonitor = function() { - clearInterval(this.idleCheck); + if(this.idleCheck) { + clearInterval(this.idleCheck); + delete this.idleCheck; + } +}; + +Client.prototype.overrideIdleLogoutSeconds = function(seconds) { + this.idleLogoutSecondsOverride = seconds; +}; + +Client.prototype.restoreIdleLogoutSeconds = function() { + delete this.idleLogoutSecondsOverride; }; Client.prototype.end = function () { diff --git a/core/config.js b/core/config.js index 14f258d8..0bb82838 100644 --- a/core/config.js +++ b/core/config.js @@ -169,6 +169,11 @@ function getDefaultConfig() { return { general : { boardName : 'Another Fine ENiGMA½ BBS', + prettyBoardName : '|08A|07nother |07F|08ine |07E|08NiGMA|07½ B|08BS', + telnetHostname : '', + sshHostname : '', + website : 'https://enigma-bbs.github.io', + description : 'An ENiGMA½ BBS', // :TODO: closedSystem prob belongs under users{}? closedSystem : false, // is the system closed to new users? @@ -212,7 +217,8 @@ function getDefaultConfig() { badUserNames : [ 'sysop', 'admin', 'administrator', 'root', 'all', - 'areamgr', 'filemgr', 'filefix', 'areafix', 'allfix' + 'areamgr', 'filemgr', 'filefix', 'areafix', 'allfix', + 'server', 'client', 'notme' ], preAuthIdleLogoutSeconds : 60 * 3, // 3m @@ -258,6 +264,7 @@ function getDefaultConfig() { mods : paths.join(__dirname, './../mods/'), loginServers : paths.join(__dirname, './servers/login/'), contentServers : paths.join(__dirname, './servers/content/'), + chatServers : paths.join(__dirname, './servers/chat/'), scannerTossers : paths.join(__dirname, './scanner_tossers/'), mailers : paths.join(__dirname, './mailers/') , @@ -452,6 +459,16 @@ function getDefaultConfig() { } }, + chatServers : { + mrc: { + enabled : false, + serverHostname : 'mrc.bottomlessabyss.net', + serverPort : 5000, + retryDelay : 10000, + multiplexerPort : 5000, + } + }, + infoExtractUtils : { Exiftool2Desc : { cmd : `${__dirname}/../util/exiftool2desc.js`, // ensure chmod +x diff --git a/core/door.js b/core/door.js index b07c89bc..c8dd3796 100644 --- a/core/door.js +++ b/core/door.js @@ -70,14 +70,13 @@ module.exports = class Door { const args = exeInfo.args.map( arg => stringFormat(arg, formatObj) ); - this.client.log.debug( + this.client.log.info( { cmd : exeInfo.cmd, args, io : this.io }, - 'Executing door' + 'Executing external door process' ); - let door; try { - door = pty.spawn(exeInfo.cmd, args, { + this.doorPty = pty.spawn(exeInfo.cmd, args, { cols : this.client.term.termWidth, rows : this.client.term.termHeight, cwd : cwd, @@ -88,15 +87,19 @@ module.exports = class Door { return cb(e); } + this.client.log.debug( + { processId : this.doorPty.pid }, 'External door process spawned' + ); + if('stdio' === this.io) { this.client.log.debug('Using stdio for door I/O'); - this.client.term.output.pipe(door); + this.client.term.output.pipe(this.doorPty); - door.on('data', this.doorDataHandler.bind(this)); + this.doorPty.on('data', this.doorDataHandler.bind(this)); - door.once('close', () => { - return this.restoreIo(door); + this.doorPty.once('close', () => { + return this.restoreIo(this.doorPty); }); } else if('socket' === this.io) { this.client.log.debug( @@ -105,7 +108,7 @@ module.exports = class Door { ); } - door.once('exit', exitCode => { + this.doorPty.once('exit', exitCode => { this.client.log.info( { exitCode : exitCode }, 'Door exited'); if(this.sockServer) { @@ -114,10 +117,11 @@ module.exports = class Door { // we may not get a close if('stdio' === this.io) { - this.restoreIo(door); + this.restoreIo(this.doorPty); } - door.removeAllListeners(); + this.doorPty.removeAllListeners(); + delete this.doorPty; return cb(null); }); @@ -128,9 +132,15 @@ module.exports = class Door { } restoreIo(piped) { - if(!this.restored && this.client.term.output) { - this.client.term.output.unpipe(piped); - this.client.term.output.resume(); + if(!this.restored) { + if(this.doorPty) { + this.doorPty.kill(); + } + + if(this.client.term.output) { + this.client.term.output.unpipe(piped); + this.client.term.output.resume(); + } this.restored = true; } } diff --git a/core/listening_server.js b/core/listening_server.js index aa573fa1..7cb7405e 100644 --- a/core/listening_server.js +++ b/core/listening_server.js @@ -28,7 +28,7 @@ function getServer(packageName) { function startListening(cb) { const moduleUtil = require('./module_util.js'); // late load so we get Config - async.each( [ 'login', 'content' ], (category, next) => { + async.each( [ 'login', 'content', 'chat' ], (category, next) => { moduleUtil.loadModulesForCategory(`${category}Servers`, (module, nextModule) => { const moduleInst = new module.getModule(); try { diff --git a/core/module_util.js b/core/module_util.js index f61929d2..033d6094 100644 --- a/core/module_util.js +++ b/core/module_util.js @@ -117,6 +117,7 @@ function getModulePaths() { config.paths.mods, config.paths.loginServers, config.paths.contentServers, + config.paths.chatServers, config.paths.scannerTossers, ]; } diff --git a/core/mrc.js b/core/mrc.js new file mode 100644 index 00000000..754e2728 --- /dev/null +++ b/core/mrc.js @@ -0,0 +1,575 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const Log = require('./logger.js').log; +const { MenuModule } = require('./menu_module.js'); +const { + pipeToAnsi, + stripMciColorCodes +} = require('./color_codes.js'); +const stringFormat = require('./string_format.js'); +const StringUtil = require('./string_util.js'); +const Config = require('./config.js').get; + +// deps +const _ = require('lodash'); +const async = require('async'); +const net = require('net'); +const moment = require('moment'); + +exports.moduleInfo = { + name : 'MRC Client', + desc : 'Connects to an MRC chat server', + author : 'RiPuk', + packageName : 'codes.l33t.enigma.mrc.client', + + // Whilst this module was put together by me (RiPuk), it should be noted that a lot of the ideas (and even some code snippets) were + // borrowed from the Synchronet implementation of MRC by echicken. So...thanks, your code was very helpful in putting this together. + // Source at http://cvs.synchro.net/cgi-bin/viewcvs.cgi/xtrn/mrc/. +}; + +const FormIds = { + mrcChat : 0, +}; + +const MciViewIds = { + mrcChat : { + chatLog : 1, + inputArea : 2, + roomName : 3, + roomTopic : 4, + mrcUsers : 5, + mrcBbses : 6, + + customRangeStart : 20, // 20+ = customs + } +}; + + + +// TODO: this is a bit shit, could maybe do it with an ansi instead +const helpText = ` +|15General Chat|08: +|03/|11rooms |08& |03/|11join |03 |08- |07List all or join a room +|03/|11pm |03 |08- |07Send a private message +---- +|03/|11whoon |08- |07Who's on what BBS +|03/|11chatters |08- |07Who's in what room +|03/|11clear |08- |07Clear back buffer +|03/|11topic |03 |08- |07Set the room topic +|03/|11bbses |08& |03/|11info |08- |07Info about BBS's connected +|03/|11meetups |08- |07Info about MRC MeetUps +--- +|03/|11l33t |03 |08- |07l337 5p34k +|03/|11kewl |03 |08- |07BBS KeWL SPeaK +|03/|11rainbow |03 |08- |07Crazy rainbow text +`; + + +exports.getModule = class mrcModule extends MenuModule { + constructor(options) { + super(options); + + this.log = Log.child( { module : 'MRC' } ); + this.config = Object.assign({}, _.get(options, 'menuConfig.config'), { extraArgs : options.extraArgs }); + + this.config.maxScrollbackLines = this.config.maxScrollbackLines || 500; + + this.state = { + socket: '', + alias: this.client.user.username, + room: '', + room_topic: '', + nicks: [], + lastSentMsg : {}, // used for latency est. + }; + + this.customFormatObj = { + roomName : '', + roomTopic : '', + roomUserCount : 0, + userCount : 0, + boardCount : 0, + roomCount : 0, + latencyMs : 0, + activityLevel : 0, + activityLevelIndicator : ' ', + }; + + this.menuMethods = { + + sendChatMessage : (formData, extraArgs, cb) => { + + const inputAreaView = this.viewControllers.mrcChat.getView(MciViewIds.mrcChat.inputArea); + const inputData = inputAreaView.getData(); + + this.processOutgoingMessage(inputData); + inputAreaView.clearText(); + + return cb(null); + }, + + movementKeyPressed : (formData, extraArgs, cb) => { + const bodyView = this.viewControllers.mrcChat.getView(MciViewIds.mrcChat.chatLog); + switch(formData.key.name) { + case 'down arrow' : bodyView.scrollDocumentUp(); break; + case 'up arrow' : bodyView.scrollDocumentDown(); break; + case 'page up' : bodyView.keyPressPageUp(); break; + case 'page down' : bodyView.keyPressPageDown(); break; + } + + this.viewControllers.mrcChat.switchFocus(MciViewIds.mrcChat.inputArea); + + return cb(null); + }, + + quit : (formData, extraArgs, cb) => { + return this.prevMenu(cb); + }, + + clearMessages : (formData, extraArgs, cb) => { + this.clearMessages(); + return cb(null); + } + }; + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + async.series( + [ + (callback) => { + return this.prepViewController('mrcChat', FormIds.mrcChat, mciData.menu, callback); + }, + (callback) => { + return this.validateMCIByViewIds('mrcChat', [ MciViewIds.mrcChat.chatLog, MciViewIds.mrcChat.inputArea ], callback); + }, + (callback) => { + const connectOpts = { + port : _.get(Config(), 'chatServers.mrc.multiplexerPort', 5000), + host : 'localhost', + }; + + // connect to multiplexer + this.state.socket = net.createConnection(connectOpts, () => { + this.client.once('end', () => { + this.quitServer(); + }); + + // handshake with multiplexer + this.state.socket.write(`--DUDE-ITS--|${this.state.alias}\n`); + + this.clientConnect(); + + // send register to central MRC and get stats every 60s + this.heartbeat = setInterval( () => { + this.sendHeartbeat(); + this.sendServerMessage('STATS'); + }, 60000); + + // override idle logout seconds if configured + const idleLogoutSeconds = parseInt(this.config.idleLogoutSeconds); + if(0 === idleLogoutSeconds) { + this.log.debug('Temporary disable idle monitor due to config'); + this.client.stopIdleMonitor(); + } else if (!isNaN(idleLogoutSeconds) && idleLogoutSeconds >= 60) { + this.log.debug( { idleLogoutSeconds }, 'Temporary override idle logout seconds due to config'); + this.client.overrideIdleLogoutSeconds(idleLogoutSeconds); + } + }); + + // when we get data, process it + this.state.socket.on('data', data => { + data = data.toString(); + this.processReceivedMessage(data); + }); + + this.state.socket.once('error', err => { + this.log.warn( { error : err.message }, 'MRC multiplexer socket error' ); + this.state.socket.destroy(); + delete this.state.socket; + + // bail with error - fall back to prev menu + return callback(err); + }); + + return(callback); + } + ], + err => { + return cb(err); + } + ); + }); + } + + leave() { + this.quitServer(); + + // restore idle monitor to previous state + this.log.debug('Restoring idle monitor to previous state'); + this.client.restoreIdleLogoutSeconds(); + this.client.startIdleMonitor(); + + return super.leave(); + } + + quitServer() { + clearInterval(this.heartbeat); + + if(this.state.socket) { + this.sendServerMessage('LOGOFF'); + this.state.socket.destroy(); + delete this.state.socket; + } + } + + /** + * Adds a message to the chat log on screen + */ + addMessageToChatLog(message) { + if(!Array.isArray(message)) { + message = [ message ]; + } + + message.forEach(msg => { + const chatLogView = this.viewControllers.mrcChat.getView(MciViewIds.mrcChat.chatLog); + const messageLength = stripMciColorCodes(msg).length; + const chatWidth = chatLogView.dimens.width; + let padAmount = 0; + let spaces = 2; + + if (messageLength > chatWidth) { + padAmount = chatWidth - (messageLength % chatWidth) - spaces; + } else { + padAmount = chatWidth - messageLength - spaces; + } + + if (padAmount < 0) padAmount = 0; + + const padding = ' |00' + ' '.repeat(padAmount); + chatLogView.addText(pipeToAnsi(msg + padding)); + + if(chatLogView.getLineCount() > this.config.maxScrollbackLines) { + chatLogView.deleteLine(0); + } + }); + } + + /** + * Processes data received from the MRC multiplexer + */ + processReceivedMessage(blob) { + blob.split('\n').forEach( message => { + + try { + message = JSON.parse(message); + } catch (e) { + return; + } + + if (message.from_user == 'SERVER') { + const params = message.body.split(':'); + + switch (params[0]) { + case 'BANNER': + this.addMessageToChatLog(params[1].replace(/^\s+/, '')); + break; + + case 'ROOMTOPIC': + this.setText(MciViewIds.mrcChat.roomName, `#${params[1]}`); + this.setText(MciViewIds.mrcChat.roomTopic, params[2]); + + this.customFormatObj.roomName = params[1]; + this.customFormatObj.roomTopic = params[2]; + this.updateCustomViews(); + + this.state.room = params[1]; + break; + + case 'USERLIST': + this.state.nicks = params[1].split(','); + + this.customFormatObj.roomUserCount = this.state.nicks.length; + this.updateCustomViews(); + break; + + case 'STATS': { + const [ + boardCount, + roomCount, + userCount, + activityLevel + ] = params[1].split(' ').map(v => parseInt(v)); + + const activityLevelIndicator = this.getActivityLevelIndicator(activityLevel); + + Object.assign( + this.customFormatObj, + { + boardCount, roomCount, userCount, + activityLevel, activityLevelIndicator + } + ); + + this.setText(MciViewIds.mrcChat.mrcUsers, userCount); + this.setText(MciViewIds.mrcChat.mrcBbses, boardCount); + + this.updateCustomViews(); + break; + } + + default: + this.addMessageToChatLog(message.body); + break; + } + + } else { + if(message.body === this.state.lastSentMsg.msg) { + this.customFormatObj.latencyMs = + moment.duration(moment().diff(this.state.lastSentMsg.time)).asMilliseconds(); + delete this.state.lastSentMsg.msg; + } + + if (message.to_room == this.state.room) { + // if we're here then we want to show it to the user + const currentTime = moment().format(this.client.currentTheme.helpers.getTimeFormat()); + this.addMessageToChatLog('|08' + currentTime + '|00 ' + message.body + '|00'); + } + } + + this.viewControllers.mrcChat.switchFocus(MciViewIds.mrcChat.inputArea); + }); + } + + getActivityLevelIndicator(level) { + let indicators = this.config.activityLevelIndicators; + if(!Array.isArray(indicators) || indicators.length < level + 1) { + indicators = [ ' ', '░', '▒', '▓' ]; + } + return indicators[level]; + } + + setText(mciId, text) { + return this.setViewText('mrcChat', mciId, text); + } + + updateCustomViews() { + return this.updateCustomViewTextsWithFilter( + 'mrcChat', + MciViewIds.mrcChat.customRangeStart, + this.customFormatObj + ); + } + + /** + * Receives the message input from the user and does something with it based on what it is + */ + processOutgoingMessage(message, to_user) { + if (message.startsWith('/')) { + this.processSlashCommand(message); + } else { + if (message == '') { + // don't do anything if message is blank, just update stats + this.sendServerMessage('STATS'); + return; + } + + // else just format and send + const textFormatObj = { + fromUserName : this.state.alias, + toUserName : to_user, + message : message + }; + + const messageFormat = + this.config.messageFormat || + '|00|10<|02{fromUserName}|10>|00 |03{message}|00'; + + const privateMessageFormat = + this.config.outgoingPrivateMessageFormat || + '|00|10<|02{fromUserName}|10|14->|02{toUserName}>|00 |03{message}|00'; + + let formattedMessage = ''; + if (to_user == undefined) { + // normal message + formattedMessage = stringFormat(messageFormat, textFormatObj); + } else { + // pm + formattedMessage = stringFormat(privateMessageFormat, textFormatObj); + } + + try { + this.state.lastSentMsg = { + msg : formattedMessage, + time : moment(), + }; + this.sendMessageToMultiplexer(to_user || '', '', this.state.room, formattedMessage); + } catch(e) { + this.client.log.warn( { error : e.message }, 'MRC error'); + } + } + + } + + /** + * Processes a message that begins with a slash + */ + processSlashCommand(message) { + const cmd = message.split(' '); + cmd[0] = cmd[0].substr(1).toLowerCase(); + + switch (cmd[0]) { + case 'pm': + this.processOutgoingMessage(cmd[2], cmd[1]); + break; + + case 'rainbow': { + // this is brutal, but i love it + const line = message.replace(/^\/rainbow\s/, '').split(' ').reduce(function (a, c) { + const cc = Math.floor((Math.random() * 31) + 1).toString().padStart(2, '0'); + a += `|${cc}${c}|00 `; + return a; + }, '').substr(0, 140).replace(/\\s\|\d*$/, ''); + + this.processOutgoingMessage(line); + break; + } + + case 'l33t': + this.processOutgoingMessage(StringUtil.stylizeString(message.substr(6), 'l33t')); + break; + + case 'kewl': { + const text_modes = Array('f','v','V','i','M'); + const mode = text_modes[Math.floor(Math.random() * text_modes.length)]; + this.processOutgoingMessage(StringUtil.stylizeString(message.substr(6), mode)); + break; + } + + case 'whoon': + this.sendServerMessage('WHOON'); + break; + + case 'motd': + this.sendServerMessage('MOTD'); + break; + + case 'meetups': + this.sendServerMessage('MEETUPS'); + break; + + case 'bbses': + this.sendServerMessage('CONNECTED'); + break; + + case 'topic': + this.sendServerMessage(`NEWTOPIC:${this.state.room}:${message.substr(7)}`); + break; + + case 'info': + this.sendServerMessage(`INFO ${cmd[1]}`); + break; + + case 'join': + this.joinRoom(cmd[1]); + break; + + case 'chatters': + this.sendServerMessage('CHATTERS'); + break; + + case 'rooms': + this.sendServerMessage('LIST'); + break; + + case 'quit' : + return this.prevMenu(); + + case 'clear': + this.clearMessages(); + break; + + case '?': + this.addMessageToChatLog(helpText.split(/\n/g)); + break; + + default: + + break; + } + + // just do something to get the cursor back to the right place ¯\_(ツ)_/¯ + // :TODO: fix me! + this.sendServerMessage('STATS'); + } + + clearMessages() { + const chatLogView = this.viewControllers.mrcChat.getView(MciViewIds.mrcChat.chatLog); + chatLogView.setText(''); + } + + /** + * Creates a json object, stringifies it and sends it to the MRC multiplexer + */ + sendMessageToMultiplexer(to_user, to_site, to_room, body) { + + const message = { + to_user, + to_site, + to_room, + body, + from_user : this.state.alias, + from_room : this.state.room, + }; + + if(this.state.socket) { + this.state.socket.write(JSON.stringify(message) + '\n'); + } + } + + /** + * Sends an MRC 'server' message + */ + sendServerMessage(command, to_site) { + Log.debug({ module: 'mrc', command: command }, 'Sending server command'); + this.sendMessageToMultiplexer('SERVER', to_site || '', this.state.room, command); + } + + /** + * Sends a heartbeat to the MRC server + */ + sendHeartbeat() { + this.sendServerMessage('IAMHERE'); + } + + /** + * Joins a room, unsurprisingly + */ + joinRoom(room) { + // room names are displayed with a # but referred to without. confusing. + room = room.replace(/^#/, ''); + this.state.room = room; + this.sendServerMessage(`NEWROOM:${this.state.room}:${room}`); + this.sendServerMessage('USERLIST'); + } + + /** + * Things that happen when a local user connects to the MRC multiplexer + */ + clientConnect() { + this.sendServerMessage('MOTD'); + this.joinRoom('lobby'); + this.sendServerMessage('STATS'); + this.sendHeartbeat(); + } +}; + + + + diff --git a/core/servers/chat/mrc_multiplexer.js b/core/servers/chat/mrc_multiplexer.js new file mode 100644 index 00000000..fd83d462 --- /dev/null +++ b/core/servers/chat/mrc_multiplexer.js @@ -0,0 +1,320 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const Log = require('../../logger.js').log; +const { ServerModule } = require('../../server_module.js'); +const Config = require('../../config.js').get; +const { Errors } = require('../../enig_error.js'); +const SysProps = require('../../system_property.js'); +const StatLog = require('../../stat_log.js'); + +// deps +const net = require('net'); +const _ = require('lodash'); +const os = require('os'); + + +// MRC +const protocolVersion = '1.2.9'; +const lineDelimiter = new RegExp('\r\n|\r|\n'); + +const ModuleInfo = exports.moduleInfo = { + name : 'MRC', + desc : 'An MRC Chat Multiplexer', + author : 'RiPuk', + packageName : 'codes.l33t.enigma.mrc.server', + notes : 'https://bbswiki.bottomlessabyss.net/index.php?title=MRC_Chat_platform', +}; + +const connectedSockets = new Set(); + +exports.getModule = class MrcModule extends ServerModule { + constructor() { + super(); + + this.log = Log.child( { server : 'MRC' } ); + + const config = Config(); + this.mrcConnectOpts = { + host : config.chatServers.mrc.serverHostname || 'mrc.bottomlessabyss.net', + port : config.chatServers.mrc.serverPort || 5000, + retryDelay : config.chatServers.mrc.retryDelay || 10000 + }; + } + + _connectionHandler() { + const config = Config(); + const boardName = config.general.prettyBoardName || config.general.boardName; + const enigmaVersion = 'ENiGMA½-BBS_' + require('../../../package.json').version; + + const handshake = `${boardName}~${enigmaVersion}/${os.platform()}.${os.arch()}/${protocolVersion}`; + this.log.debug({ handshake : handshake }, 'Handshaking with MRC server'); + + this.sendRaw(handshake); + this.log.info(this.mrcConnectOpts, 'Connected to MRC server'); + } + + createServer(cb) { + + if (!this.enabled) { + return cb(null); + } + + this.connectToMrc(); + this.createLocalListener(); + + return cb(null); + } + + listen(cb) { + if (!this.enabled) { + return cb(null); + } + + const config = Config(); + + const port = parseInt(config.chatServers.mrc.multiplexerPort); + if(isNaN(port)) { + this.log.warn( { port : config.chatServers.mrc.multiplexerPort, server : ModuleInfo.name }, 'Invalid port' ); + return cb(Errors.Invalid(`Invalid port: ${config.chatServers.mrc.multiplexerPort}`)); + } + Log.info( { server : ModuleInfo.name, port : config.chatServers.mrc.multiplexerPort }, 'MRC multiplexer starting up'); + return this.server.listen(port, cb); + } + + /** + * Handles connecting to to the MRC server + */ + connectToMrc() { + const self = this; + + // create connection to MRC server + this.mrcClient = net.createConnection(this.mrcConnectOpts, self._connectionHandler.bind(self)); + + this.mrcClient.requestedDisconnect = false; + + // do things when we get data from MRC central + let buffer = new Buffer.from(''); + + function handleData(chunk) { + if(_.isString(chunk)) { + buffer += chunk; + } else { + buffer = Buffer.concat([buffer, chunk]); + } + + let lines = buffer.toString().split(lineDelimiter); + + if (lines.pop()) { + // if buffer is not ended with \r\n, there's more chunks. + return; + } else { + // else, initialize the buffer. + buffer = new Buffer.from(''); + } + + lines.forEach( line => { + if (line.length) { + let message = self.parseMessage(line); + if (message) { + self.receiveFromMRC(message); + } + } + }); + } + + this.mrcClient.on('data', (data) => { + handleData(data); + }); + + this.mrcClient.on('end', () => { + this.log.info(this.mrcConnectOpts, 'Disconnected from MRC server'); + }); + + this.mrcClient.on('close', () => { + + if (this.mrcClient && this.mrcClient.requestedDisconnect) + return; + + this.log.info(this.mrcConnectOpts, 'Disconnected from MRC server, reconnecting'); + this.log.debug('Waiting ' + this.mrcConnectOpts.retryDelay + 'ms before retrying'); + + setTimeout(function() { + self.connectToMrc(); + }, this.mrcConnectOpts.retryDelay); + }); + + this.mrcClient.on('error', err => { + this.log.info( { error : err.message }, 'MRC server error'); + }); + } + + createLocalListener() { + // start a local server for clients to connect to + + this.server = net.createServer( socket => { + socket.setEncoding('ascii'); + + socket.on('data', data => { + // split on \n to deal with getting messages in batches + data.toString().split(lineDelimiter).forEach( item => { + if (item == '') return; + + // save username with socket + if(item.startsWith('--DUDE-ITS--')) { + connectedSockets.add(socket); + socket.username = item.split('|')[1]; + Log.debug( { server : 'MRC', user: socket.username } , 'User connected'); + } else { + this.receiveFromClient(socket.username, item); + } + }); + }); + + socket.on('end', function() { + connectedSockets.delete(socket); + }); + + socket.on('error', err => { + if('ECONNRESET' !== err.code) { // normal + this.log.error( { error: err.message }, 'MRC error' ); + } + }); + }); + } + + get enabled() { + return _.get(Config(), 'chatServers.mrc.enabled', false) && this.isConfigured(); + } + + isConfigured() { + const config = Config(); + return _.isNumber(_.get(config, 'chatServers.mrc.multiplexerPort')); + } + + /** + * Sends received messages to local clients + */ + sendToClient(message) { + connectedSockets.forEach( (client) => { + if (message.to_user == '' || message.to_user == client.username || message.to_user == 'CLIENT' || message.from_user == client.username || message.to_user == 'NOTME' ) { + // this.log.debug({ server : 'MRC', username : client.username, message : message }, 'Forwarding message to connected user'); + client.write(JSON.stringify(message) + '\n'); + } + }); + } + + /** + * Processes messages received from the central MRC server + */ + receiveFromMRC(message) { + + const config = Config(); + const siteName = slugify(config.general.boardName); + + if (message.from_user == 'SERVER' && message.body == 'HELLO') { + // reply with extra bbs info + this.sendToMrcServer('CLIENT', '', 'SERVER', 'ALL', '', `INFOSYS:${StatLog.getSystemStat(SysProps.SysOpUsername)}`); + this.sendToMrcServer('CLIENT', '', 'SERVER', 'ALL', '', `INFOWEB:${config.general.website}`); + this.sendToMrcServer('CLIENT', '', 'SERVER', 'ALL', '', `INFOTEL:${config.general.telnetHostname}`); + this.sendToMrcServer('CLIENT', '', 'SERVER', 'ALL', '', `INFOSSH:${config.general.sshHostname}`); + this.sendToMrcServer('CLIENT', '', 'SERVER', 'ALL', '', `INFODSC:${config.general.description}`); + + } else if (message.from_user == 'SERVER' && message.body.toUpperCase() == 'PING') { + // reply to heartbeat + this.sendToMrcServer('CLIENT', '', 'SERVER', 'ALL', '', `IMALIVE:${siteName}`); + + } else { + // if not a heartbeat, and we have clients then we need to send something to them + this.sendToClient(message); + } + } + + /** + * Takes an MRC message and parses it into something usable + */ + parseMessage(line) { + + const [from_user, from_site, from_room, to_user, to_site, to_room, body ] = line.split('~'); + + // const msg = line.split('~'); + // if (msg.length < 7) { + // return; + // } + + return { from_user, from_site, from_room, to_user, to_site, to_room, body }; + } + + /** + * Receives a message from a local client and sanity checks before sending on to the central MRC server + */ + receiveFromClient(username, message) { + try { + message = JSON.parse(message); + } catch (e) { + Log.debug({ server : 'MRC', user : username, message : message }, 'Dodgy message received from client'); + } + + this.sendToMrcServer(message.from_user, message.from_room, message.to_user, message.to_site, message.to_room, message.body); + } + + /** + * Converts a message back into the MRC format and sends it to the central MRC server + */ + sendToMrcServer(fromUser, fromRoom, toUser, toSite, toRoom, messageBody) { + const config = Config(); + const siteName = slugify(config.general.boardName); + + const line = [ + fromUser, + siteName, + sanitiseRoomName(fromRoom), + sanitiseName(toUser || ''), + sanitiseName(toSite || ''), + sanitiseRoomName(toRoom || ''), + sanitiseMessage(messageBody) + ].join('~') + '~'; + + // Log.debug({ server : 'MRC', data : line }, 'Sending data'); + this.sendRaw(line); + } + + sendRaw(message) { + // optionally log messages here + this.mrcClient.write(message + '\n'); + } +}; + +/** + * User / site name must be ASCII 33-125, no MCI, 30 chars max, underscores + */ +function sanitiseName(str) { + return str.replace( + /\s/g, '_' + ).replace( + /[^\x21-\x7D]|(\|\w\w)/g, '' // Non-printable & MCI + ).substr( + 0, 30 + ); +} + +function sanitiseRoomName(message) { + return message.replace(/[^\x21-\x7D]|(\|\w\w)/g, '').substr(0, 30); +} + +function sanitiseMessage(message) { + return message.replace(/[^\x20-\x7D]/g, ''); +} + +/** + * SLugifies the BBS name for use as an MRC "site name" + */ +function slugify(text) { + return text.toString() + .replace(/\s+/g, '_') // Replace spaces with _ + .replace(/[^\w\-]+/g, '') // Remove all non-word chars + .replace(/\-\-+/g, '_') // Replace multiple - with single - + .replace(/^-+/, '') // Trim - from start of text + .replace(/-+$/, ''); // Trim - from end of text +} \ No newline at end of file diff --git a/core/view.js b/core/view.js index b675b508..fdf78916 100644 --- a/core/view.js +++ b/core/view.js @@ -226,6 +226,12 @@ View.prototype.setPropertyValue = function(propName, value) { case 'argName' : this.submitArgName = value; break; + case 'omit' : + if(_.isBoolean(value)) { + this.omitFromSubmission = value; break; + } + break; + case 'validate' : if(_.isFunction(value)) { this.validate = value; diff --git a/core/view_controller.js b/core/view_controller.js index 84f33756..9c0ffe19 100644 --- a/core/view_controller.js +++ b/core/view_controller.js @@ -46,6 +46,8 @@ function ViewController(options) { return; // ignore until this is finished! } + self.client.log.trace( { actionBlock }, 'Action match' ); + self.waitActionCompletion = true; menuUtil.handleAction(self.client, formData, actionBlock, (err) => { if(err) { @@ -121,9 +123,7 @@ function ViewController(options) { self.emit('submit', this.getFormData(key)); }; - // :TODO: replace this in favor of overriding toJSON() for various things such that logging will *never* output them this.getLogFriendlyFormData = function(formData) { - // :TODO: these fields should be part of menu.json sensitiveMembers[] var safeFormData = _.cloneDeep(formData); if(safeFormData.value.password) { safeFormData.value.password = '*****'; @@ -330,15 +330,6 @@ function ViewController(options) { } } } - - self.client.log.trace( - { - formValue : formValue, - actionValue : actionValue - }, - 'Action match' - ); - return true; }; @@ -577,7 +568,7 @@ ViewController.prototype.loadFromPromptConfig = function(options, cb) { if(false === self.noInput) { self.on('submit', function promptSubmit(formData) { - self.client.log.trace( { formData : self.getLogFriendlyFormData(formData) }, 'Prompt submit'); + self.client.log.trace( { formData }, 'Prompt submit'); const doSubmitNotify = () => { if(options.submitNotify) { @@ -752,8 +743,7 @@ ViewController.prototype.loadFromMenuConfig = function(options, cb) { } self.on('submit', function formSubmit(formData) { - - self.client.log.trace( { formData : self.getLogFriendlyFormData(formData) }, 'Form submit'); + self.client.log.trace( { formData }, 'Form submit'); // // Locate configuration for this form ID @@ -870,6 +860,11 @@ ViewController.prototype.getFormData = function(key) { return; } + // some form values may be omitted from submission all together + if(view.omitFromSubmission) { + return; + } + viewData = view.getData(); if(_.isUndefined(viewData)) { return; diff --git a/misc/config_template.in.hjson b/misc/config_template.in.hjson index 38482a6a..abcdc09a 100644 --- a/misc/config_template.in.hjson +++ b/misc/config_template.in.hjson @@ -50,6 +50,21 @@ general: { // Your BBS Name! boardName: XXXXX + + // Your BBS name, with pipe codes for styling + prettyBoardName : '|08XXXXX' + + // Telnet hostname and port for your board + telnetHostname : 'xibalba.l33t.codes:44510' + + // SSH hostname and port for your board + sshHostname : 'xibalba.l33t.codes:44511' + + // Your board's website + website : 'https://enigma-bbs.github.io' + + // Short board description + description : 'Yet another awesome ENiGMA½ BBS' } paths: { @@ -274,6 +289,16 @@ } } + chatServers: { + // multi relay chat settings. No need to sign up, just enable it. + // More info: https://bbswiki.bottomlessabyss.net/index.php?title=MRC_Chat_platform + mrc: { + enabled : false + serverHostname : 'mrc.bottomlessabyss.net' + serverPort : 5000 + } + } + // // Currently, ENiGMA½ can use external email to mail // users for password resets. Additional functionality will diff --git a/misc/menu_template.in.hjson b/misc/menu_template.in.hjson index 9aac86f3..5c7eba83 100644 --- a/misc/menu_template.in.hjson +++ b/misc/menu_template.in.hjson @@ -1066,6 +1066,10 @@ value: { command: "UA" } action: @menu:mainMenuUserAchievementsEarned } + { + value: { command: "MRC" } + action: @menu:mrc + } { value: 1 action: @menu:mainMenu @@ -1094,6 +1098,51 @@ } } + mrc: { + desc: MRC Chat + module: mrc + art: MRC + config: { + cls: true + + // max lines kept in scrollback buffer + maxScrollbackLines: 500 + } + form: { + 0: { + mci: { + MT1: { + mode: preview + autoScroll: true + } + ET2: { + argName: inputArea + submit: true + focus: true + } + } + actionKeys: [ + { + keys: [ "escape" ] + action: @systemMethod:prevMenu + } + { + keys: [ "down arrow", "up arrow", "page up", "page down" ] + action: @method:movementKeyPressed + } + ] + submit: { + *: [ + { + value: { inputArea: null } + action: @method:sendChatMessage + } + ] + } + } + } + } + nodeMessage: { desc: Node Messaging module: node_msg