diff --git a/core/config.js b/core/config.js index 5c1f9c42..854e65c8 100644 --- a/core/config.js +++ b/core/config.js @@ -212,7 +212,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 @@ -253,6 +254,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/') , @@ -447,6 +449,15 @@ function getDefaultConfig() { } }, + chatServers : { + mrc: { + enabled : true, + multiplexerPort : 5000, + serverHostname : "mrc.bottomlessabyss.com", + serverPort : 5000 + } + }, + infoExtractUtils : { Exiftool2Desc : { cmd : `${__dirname}/../util/exiftool2desc.js`, // ensure chmod +x @@ -965,38 +976,38 @@ function getDefaultConfig() { eventScheduler : { events : { - dailyMaintenance : { - schedule : 'at 11:59pm', - action : '@method:core/misc_scheduled_events.js:dailyMaintenanceScheduledEvent', - }, - trimMessageAreas : { - // may optionally use [or ]@watch:/path/to/file - schedule : 'every 24 hours', + // dailyMaintenance : { + // schedule : 'at 11:59pm', + // action : '@method:core/misc_scheduled_events.js:dailyMaintenanceScheduledEvent', + // }, + // trimMessageAreas : { + // // may optionally use [or ]@watch:/path/to/file + // schedule : 'every 24 hours', - // action: - // - @method:path/to/module.js:theMethodName - // (path is relative to ENiGMA base dir) - // - // - @execute:/path/to/something/executable.sh - // - action : '@method:core/message_area.js:trimMessageAreasScheduledEvent', - }, + // // action: + // // - @method:path/to/module.js:theMethodName + // // (path is relative to ENiGMA base dir) + // // + // // - @execute:/path/to/something/executable.sh + // // + // action : '@method:core/message_area.js:trimMessageAreasScheduledEvent', + // }, - nntpMaintenance : { - schedule : 'every 12 hours', // should generally be < trimMessageAreas interval - action : '@method:core/servers/content/nntp.js:performMaintenanceTask', - }, + // nntpMaintenance : { + // schedule : 'every 12 hours', // should generally be < trimMessageAreas interval + // action : '@method:core/servers/content/nntp.js:performMaintenanceTask', + // }, - updateFileAreaStats : { - schedule : 'every 1 hours', - action : '@method:core/file_base_area.js:updateAreaStatsScheduledEvent', - }, + // updateFileAreaStats : { + // schedule : 'every 1 hours', + // action : '@method:core/file_base_area.js:updateAreaStatsScheduledEvent', + // }, - forgotPasswordMaintenance : { - schedule : 'every 24 hours', - action : '@method:core/web_password_reset.js:performMaintenanceTask', - args : [ '24 hours' ] // items older than this will be removed - }, + // forgotPasswordMaintenance : { + // schedule : 'every 24 hours', + // action : '@method:core/web_password_reset.js:performMaintenanceTask', + // args : [ '24 hours' ] // items older than this will be removed + // }, // // Enable the following entry in your config.hjson to periodically create/update 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..b6e68463 --- /dev/null +++ b/core/mrc.js @@ -0,0 +1,233 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const Log = require('./logger.js').log; +const { MenuModule } = require('./menu_module.js'); +const { Errors } = require('./enig_error.js'); +const { + pipeToAnsi, + stripMciColorCodes +} = require('./color_codes.js'); +const stringFormat = require('./string_format.js'); +const { getThemeArt } = require('./theme.js'); + + +// 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', +}; + +const FormIds = { + mrcChat : 0, +}; + +var MciViewIds = { + mrcChat : { + chatLog : 1, + inputArea : 2, + roomName : 3, + roomTopic : 4, + + customRangeStart : 10, // 10+ = customs + } +}; + +const state = { + socket: '', + alias: '', + room: '', + room_topic: '', + nicks: [], + last_ping: 0 +}; + + +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 }); + state.alias = this.client.user.username; + + + this.menuMethods = { + sendChatMessage : (formData, extraArgs, cb) => { + // const message = _.get(formData.value, 'inputArea', '').trim(); + + const inputAreaView = this.viewControllers.mrcChat.getView(MciViewIds.mrcChat.inputArea); + const inputData = inputAreaView.getData(); + const textFormatObj = { + fromUserName : state.alias, + message : inputData + }; + + const messageFormat = + this.config.messageFormat || + '|00|10<|02{fromUserName}|10>|00 |03{message}|00'; + + try { + sendChat(stringFormat(messageFormat, textFormatObj)); + } catch(e) { + self.client.log.warn( { error : e.message }, 'MRC error'); + } + inputAreaView.clearText(); + + return cb(null); + + } + } + } + + mciReady(mciData, cb) { + super.mciReady(mciData, err => { + if(err) { + return cb(err); + } + + async.series( + [ + // (callback) => { + // console.log("stop idle monitor") + // this.client.stopIdleMonitor(); + // return(callback); + // }, + (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 : 5000, + host : "localhost", + }; + + // connect to multiplexer + state.socket = net.createConnection(connectOpts, () => { + // handshake with multiplexer + state.socket.write(`--DUDE-ITS--|${state.alias}\n`); + + + sendClientConnect() + + // send register to central MRC every 60s + setInterval(function () { + sendHeartbeat(state.socket) + }, 60000); + }); + + // when we get data, process it + state.socket.on('data', data => { + data = data.toString(); + this.processReceivedMessage(data); + this.viewControllers.mrcChat.switchFocus(MciViewIds.mrcChat.inputArea); + }); + + return(callback); + } + ], + err => { + return cb(err); + } + ); + }); + } + + 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': + const chatMessageView = this.viewControllers.mrcChat.getView(MciViewIds.mrcChat.chatLog); + chatMessageView.addText(pipeToAnsi(params[1].replace(/^\s+/, ''))); + chatMessageView.redraw(); + + case 'ROOMTOPIC': + this.viewControllers.mrcChat.getView(MciViewIds.mrcChat.roomName).setText(params[1]); + this.viewControllers.mrcChat.getView(MciViewIds.mrcChat.roomTopic).setText(params[2]); + + case 'USERLIST': + state.nicks = params[1].split(','); + + break; + } + + } else { + // if we're here then we want to show it to the user + const chatMessageView = this.viewControllers.mrcChat.getView(MciViewIds.mrcChat.chatLog); + const currentTime = moment().format(this.client.currentTheme.helpers.getTimeFormat()); + chatMessageView.addText(pipeToAnsi("|08" + currentTime + "|00 " + message.body)); + chatMessageView.redraw(); + } + + return; + + }); + } + +}; + + +function sendMessage(to_user, to_site, to_room, body) { + // drop message if user just mashes enter + if (body == '' || body == state.alias) return; + + // otherwise construct message + const message = { + from_room: state.room, + to_user: to_user, + to_site: to_site, + to_room: to_room, + body: body + } + Log.debug({module: 'mrcclient', message: message}, 'Sending message to MRC multiplexer'); + // TODO: check socket still exists here + state.socket.write(JSON.stringify(message) + '\n'); +} + +function sendChat(message,to_user) { + sendMessage(to_user || '', '', state.room, message) +} + +function sendServerCommand(command, to_site) { + Log.debug({ module: 'mrc', command: command }, 'Sending server command'); + sendMessage('SERVER', to_site || '', state.room, command); + return; +} + +function sendHeartbeat() { + sendServerCommand('IAMHERE'); + return; +} + +function sendClientConnect() { + sendHeartbeat(); + joinRoom('lobby'); + sendServerCommand('BANNERS'); + sendServerCommand('MOTD'); + return; +} + +function joinRoom(room) { + sendServerCommand(`NEWROOM:${state.room}:${room}`); +} diff --git a/core/servers/chat/mrc_multiplexer.js b/core/servers/chat/mrc_multiplexer.js new file mode 100644 index 00000000..5419088a --- /dev/null +++ b/core/servers/chat/mrc_multiplexer.js @@ -0,0 +1,255 @@ +/* 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 { wordWrapText } = require('../../word_wrap.js'); +const { stripMciColorCodes } = require('../../color_codes.js'); + +// deps +const net = require('net'); +const _ = require('lodash'); +const os = require('os'); + +// MRC +const PROTOCOL_VERSION = '1.2.9'; + +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(); +let mrcCentralConnection = ''; + +exports.getModule = class MrcModule extends ServerModule { + constructor() { + super(); + + this.log = Log.child( { server : 'MRC' } ); + + } + + createServer(cb) { + if (!this.enabled) { + return cb(null); + } + + const config = Config(); + const boardName = config.general.boardName + const enigmaVersion = "ENiGMA-BBS_" + require('../../../package.json').version + + const mrcConnectOpts = { + port : 5000, + host : "mrc.bottomlessabyss.net" + }; + + const handshake = `${boardName}~${enigmaVersion}/${os.platform()}-${os.arch()}/${PROTOCOL_VERSION}` + this.log.debug({ handshake : handshake }, "Handshaking with MRC server") + + // create connection to MRC server + this.mrcClient = net.createConnection(mrcConnectOpts, () => { + this.mrcClient.write(handshake); + this.log.info(mrcConnectOpts, 'Connected to MRC server'); + mrcCentralConnection = this.mrcClient + }); + + // do things when we get data from MRC central + this.mrcClient.on('data', (data) => { + // split on \n to deal with getting messages in batches + data.toString().split('\n').forEach( item => { + if (item == '') return; + + this.log.debug( { data : item } , `Received data`); + let message = this.parseMessage(item); + this.log.debug(message, `Parsed data`); + + this.receiveFromMRC(this.mrcClient, message); + }); + }); + + this.mrcClient.on('end', () => { + this.log.info(mrcConnectOpts, 'Disconnected from MRC server'); + }); + + this.mrcClient.on('error', err => { + Log.info( { error : err.message }, 'MRC server error'); + }); + + // start a local server for clients to connect to + this.server = net.createServer( function(socket) { + socket.setEncoding('ascii'); + connectedSockets.add(socket); + + socket.on('data', data => { + // split on \n to deal with getting messages in batches + data.toString().split('\n').forEach( item => { + if (item == '') return; + + // save username with socket + if(item.startsWith('--DUDE-ITS--')) { + socket.username = item.split('|')[1]; + Log.debug( { server : 'MRC', user: socket.username } , `User connected`); + } + else { + receiveFromClient(socket.username, item); + } + }); + + }); + + socket.on('end', function() { + connectedSockets.delete(socket); + }); + + socket.on('error', err => { + if('ECONNRESET' !== err.code) { // normal + console.log(err.message); + } + }); + }); + + + 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 local listener starting up'); + return this.server.listen(port, cb); + } + + get enabled() { + return _.get(Config(), 'chatServers.mrc.enabled', false) && this.isConfigured(); + } + + isConfigured() { + const config = Config(); + return _.isNumber(_.get(config, 'chatServers.mrc.multiplexerPort')); + } + + sendToClient(message, username) { + connectedSockets.forEach( (client) => { + this.log.debug({ server : 'MRC', username : client.username, message : message }, 'Forwarding message to connected user') + client.write(JSON.stringify(message) + '\n'); + }); + } + + receiveFromMRC(socket, message) { + + const config = Config(); + const siteName = slugify(config.general.boardName) + + if (message.from_user == 'SERVER' && message.body == 'HELLO') { + // initial server hello, can ignore + return; + + } else if (message.from_user == 'SERVER' && message.body.toUpperCase() == 'PING') { + // reply to heartbeat + // this.log.debug('Respond to heartbeat'); + let message = sendToMrcServer(socket, 'CLIENT', '', 'SERVER', 'ALL', '', `IMALIVE:${siteName}`); + return message; + + } else { + // if not a heartbeat, and we have clients then we need to send something to them + //console.log(this.connectedSockets); + this.sendToClient(message); + return; + } + } + + // split raw data received into an object we can work with + parseMessage(line) { + const msg = line.split('~'); + if (msg.length < 7) { + return; + } + + return { + from_user: msg[0], + from_site: msg[1], + from_room: msg[2], + to_user: msg[3], + to_site: msg[4], + to_room: msg[5], + body: msg[6] + }; + } + +}; + + +// 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, ''); +} + +function receiveFromClient(username, message) { + try { + message = JSON.parse(message) + message.from_user = username + } catch (e) { + Log.debug({ server : 'MRC', user : username, message : message }, 'Dodgy message received from client'); + } + + sendToMrcServer(mrcCentralConnection, message.from_user, message.from_room, message.to_user, message.to_site, message.to_room, message.body) +} + +// send a message back to the mrc central server +function sendToMrcServer(socket, 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'); + return socket.write(line + '\n'); +} + +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