diff --git a/xtrn/mrc/mrc-client.example.ini b/xtrn/mrc/mrc-client.example.ini new file mode 100644 index 0000000000000000000000000000000000000000..879cdea556ab93338061465b1a8d590cead9ce65 --- /dev/null +++ b/xtrn/mrc/mrc-client.example.ini @@ -0,0 +1,10 @@ +server = localhost +port = 5000 +ping_interval = 60 + +[startup] +room = lobby +motd = true +banners = true + +[aliases] diff --git a/xtrn/mrc/mrc-client.js b/xtrn/mrc/mrc-client.js new file mode 100644 index 0000000000000000000000000000000000000000..d425291ef5ffd8c50a25c1c83b3f3c9e6529b469 --- /dev/null +++ b/xtrn/mrc/mrc-client.js @@ -0,0 +1,351 @@ +/* $Id$ */ + +/** + * Multi Relay Chat Client Module + * echicken -at- bbs.electronicchicken.com + * + * I started out with good intentions. At least it works. + */ + +load('sbbsdefs.js'); +load('frame.js'); +load('scrollbar.js'); +load('inputline.js'); +load(js.startup_dir + 'mrc-session.js'); + +var f = new File(js.startup_dir + 'mrc-client.ini'); +f.open('r'); +const settings = { + root: f.iniGetObject(), + startup: f.iniGetObject('startup'), + aliases: f.iniGetObject('aliases') || {} +}; +f.close(); +f = undefined; + +const NICK_COLOURS = [ + '\1h\1r', + '\1h\1g', + '\1h\1y', + '\1h\1b', + '\1h\1m', + '\1h\1c', + '\1h\1w', + // Low colours with reasonable contrast + '\1n\1r', + '\1n\1g', + '\1n\1y', + '\1n\1m', + '\1n\1c', + '\1n\1w' +]; + +const PIPE_COLOURS = [2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14, 15]; + +function pipe_to_ctrl_a(str) { + str = str.replace(/\|00/g, "\1N\1K"); + str = str.replace(/\|01/g, "\1N\1B"); + str = str.replace(/\|02/g, "\1N\1G"); + str = str.replace(/\|03/g, "\1N\1C"); + str = str.replace(/\|04/g, "\1N\1R"); + str = str.replace(/\|05/g, "\1N\1M"); + str = str.replace(/\|06/g, "\1N\1Y"); + str = str.replace(/\|07/g, "\1N\1W"); + str = str.replace(/\|08/g, "\1H\1K"); + str = str.replace(/\|09/g, "\1H\1B"); + str = str.replace(/\|10/g, "\1H\1G"); + str = str.replace(/\|11/g, "\1H\1C"); + str = str.replace(/\|12/g, "\1H\1R"); + str = str.replace(/\|13/g, "\1H\1M"); + str = str.replace(/\|14/g, "\1H\1Y"); + str = str.replace(/\|15/g, "\1H\1W"); + str = str.replace(/\|16/g, "\001" + 0); + str = str.replace(/\|17/g, "\001" + 4); + str = str.replace(/\|18/g, "\001" + 2); + str = str.replace(/\|19/g, "\001" + 6); + str = str.replace(/\|20/g, "\001" + 1); + str = str.replace(/\|21/g, "\001" + 5); + str = str.replace(/\|22/g, "\001" + 3); + str = str.replace(/\|23/g, "\001" + 7); + return str; +} + +function resize_nicklist(frames, nicks) { + const maxlen = Math.max(1, nicks.reduce(function (a, c) { + return c.length > a ? c.length : a; + }, 0)); + frames.nicklist.moveTo(frames.top.x + frames.top.width - 1 - maxlen - 1, 2); + frames.nicklist_divider.moveTo(frames.nicklist.x, 2); + frames.nicks.moveTo(frames.nicklist.x + 1, 2); + frames.nicklist.width = maxlen + 2; + frames.nicks.width = maxlen + 1; + frames.output.width = frames.top.width - frames.nicklist.width - 1; +} + +function redraw_nicklist(frames, nicks, colours) { + frames.nicks.clear(); + nicks.forEach(function (e, i) { + frames.nicks.gotoxy(1, i + 1); + frames.nicks.putmsg(colours[e] + e + '\1n\1w'); + }); +} + +function init_display() { + const w = console.screen_columns; + const h = console.screen_rows; + const f = { top: new Frame(1, 1, w, h, BG_BLACK|LIGHTGRAY) }; + f.title = new Frame(1, 1, w, 1, BG_BLUE|WHITE, f.top); + f.output = new Frame(1, 2, 1, h - 3, BG_BLACK|LIGHTGRAY, f.top); + f.divider = new Frame(1, h - 1, w, 1, BG_BLUE|WHITE, f.top); + f.nicklist = new Frame(w - 2, 2, 2, h - 3, BG_BLACK|LIGHTGRAY, f.top); + f.nicklist_divider = new Frame(w - 2, 2, 1, h - 3, BG_BLACK|LIGHTGRAY, f.nicklist); + f.nicks = new Frame(w - 1, 2, 1, h - 3, BG_BLACK|LIGHTGRAY, f.nicklist); + f.input = new Frame(1, h, w, 1, BG_BLACK|WHITE, f.top); + for (var n = 0; n < f.nicklist_divider.height; n++) { + f.nicklist_divider.gotoxy(1, n + 1); + f.nicklist_divider.putmsg(ascii(179)); + } + f.output_scroll = new ScrollBar(f.output, { autohide: true }); + f.nick_scroll = new ScrollBar(f.nicks, { autohide: true }); + f.output.word_wrap = true; + f.divider.gotoxy(f.divider.width - 5, 1); + f.divider.putmsg('/help'); + f.top.open(); + return f; +} + +function display_message(frames, msg, colour) { + const body = pipe_to_ctrl_a(msg.body || '').split(' '); + frames.output.putmsg(body[0] + '\1n\1w: ' + body.slice(1).join(' ') + '\1n\1w\r\n'); +} + +function display_server_message(frames, msg) { + frames.output.putmsg('\1h\1w' + pipe_to_ctrl_a(msg || '') + '\r\n'); +} + +function display_title(frames, room, title) { + frames.title.clear(); + frames.title.putmsg('MRC - #' + room + ' - ' + title); +} + +function set_alias(alias) { + const f = new File(js.startup_dir + 'mrc-client.ini'); + f.open('r+'); + f.iniSetValue('aliases', user.alias, alias); + f.close(); +} + +function new_alias() { + const alias = format( + '|%02d%s', + PIPE_COLOURS[Math.floor(Math.random() * PIPE_COLOURS.length)], + user.alias.replace(/\s/g, '_') + ); + set_alias(alias); + settings.aliases[user.alias] = alias; +} + +function main() { + + var msg; + var input_state = 'chat'; + var break_loop = false; + const nick_colours = {}; + + const session = new MRC_Session( + settings.root.server, + settings.root.port, + user.alias, + user.security.password, + settings.aliases[user.alias] || new_alias(user.alias) + ); + + const frames = init_display(); + const inputline = new InputLine(frames.input); + inputline.show_cursor = true; + inputline.max_buffer = 140 - settings.aliases[user.alias].length - 1; + + resize_nicklist(frames, []); + redraw_nicklist(frames, []); + + session.connect(); + session.on('banner', function (msg) { + display_server_message(frames, msg); + }); + session.on('disconnect', function () { + break_loop = true; + }); + session.on('error', function (err) { + display_message(frames, { from_user: 'ERROR', body: err }); + }); + session.on('help', function (cmd, help, ars) { + if (!ars || user.compare_ars(ars)) { + display_server_message(frames, format('\1h\1w/\1h\1c%s \1h\1w- \1n\1w%s', cmd, help)); + } + }); + session.on('local_help', function (msg) { + display_server_message(frames, '\1h\1w/\1h\1cscroll \1h\1w- \1n\1wScroll the output area'); + display_server_message(frames, '\1h\1w/\1h\1cscroll_nicks \1h\1w- \1n\1wScroll the nicklist'); + display_server_message(frames, '\1h\1w/\1h\1cnick_prefix \1h\1w- \1n\1wSet a single-character prefix for your handle, eg. /nick_prefix @'); + display_server_message( + frames, + '\1h\1w/\1h\1cnick_color \1h\1w- \1n\1wSet your nick color to one of ' + + PIPE_COLOURS.reduce(function (a, c) { + a += format('|%02d%s ', c, c); + return a; + }, '') + + ', eg. /nick_color 11' + ); + display_server_message(frames, '\1h\1w/\1h\1cnick_suffix \1h\1w- \1n\1wSet an eight-character suffix for your handle, eg. /nick_suffix <poop>'); + display_server_message(frames, '\1h\1w/\1h\1cquit \1n\1w- \1h\1wExit the program'); + }); + session.on('message', function (msg) { + if (msg.from_user == 'SERVER') { + display_server_message(frames, msg.body); + } else { + display_message(frames, msg, nick_colours[msg.from_user] || ''); + } + }); + session.on('nicks', function (nicks) { + nicks.forEach(function (e, i) { + nick_colours[e] = NICK_COLOURS[i % NICK_COLOURS.length]; + }); + resize_nicklist(frames, nicks); + redraw_nicklist(frames, nicks, nick_colours); + }); + session.on('sent_privmsg', function (user, msg) { + display_message(frames, { body: '--> ' + (nick_colours[user] || '') + user + ' ' + msg }); + }); + session.on('topic', function (room, topic) { + display_title(frames, room, topic); + }); + + if (settings.startup.motd) session.motd(); + if (settings.startup.banners) session.banners(); + if (settings.startup.room) session.join(settings.startup.room); + + var cmd, line, user_input; + while (!js.terminated && !break_loop) { + session.cycle(); + user_input = inputline.getkey(); + if (typeof user_input != 'undefined') { + if (input_state == 'chat') { + if (user_input.substring(0, 1) == '/') { // It's a command + cmd = user_input.split(' '); + cmd[0] = cmd[0].substr(1).toLowerCase(); + switch (cmd[0]) { + case 'rainbow': + line = user_input.replace(/^\/rainbow\s/, '').split('').reduce(function (a, c) { + var cc = PIPE_COLOURS[Math.floor(Math.random() * PIPE_COLOURS.length)]; + a += format('|%02d%s', cc, c); + return a; + }, '').substr(0, inputline.max_buffer).replace(/\\s\|\d*$/, ''); + session.send_room_message(line); + break; + case 'scroll': + input_state = 'scroll'; + frames.divider.clear(); + frames.divider.putmsg('UP/DOWN to scroll, ENTER to return'); + break; + case 'scroll_nicks': + input_state = 'scroll_nicks'; + frames.divider.clear(); + frames.divider.putmsg('UP/DOWN to scroll nicklist, ENTER to return'); + break; + case 'nick_prefix': + if (cmd.length == 2) { + if (cmd[1].length == 1 || cmd[1].search(/^\|\d\d\S$/) == 0) { + var re = new RegExp('^(\\|\\d\\d)*\\S*' + user.alias, 'i'); + settings.aliases[user.alias] = settings.aliases[user.alias].replace(re, cmd[1] + user.alias); + set_alias(settings.aliases[user.alias]); + session.alias = settings.aliases[user.alias]; + } + } + break; + case 'nick_color': + case 'nick_colour': + if (cmd.length == 2) { + if (PIPE_COLOURS.indexOf(parseInt(cmd[1])) >= 0) { + var re = new RegExp('(\\|\\d\\d)*' + user.alias, 'i'); + settings.aliases[user.alias] = settings.aliases[user.alias].replace(re, format('|%02d%s', cmd[1], user.alias)); + set_alias(settings.aliases[user.alias]); + session.alias = settings.aliases[user.alias]; + } + } + break; + case 'nick_suffix': + if (cmd.length == 2) { + if (cmd[1].replace(/(\\|\\d\\d)/g, '').length <= 8) { + var re = new RegExp(user.alias + '.*$', 'i'); + settings.aliases[user.alias] = settings.aliases[user.alias].replace(re, user.alias + cmd[1]); + set_alias(settings.aliases[user.alias]); + session.alias = settings.aliases[user.alias]; + } + } else { // Clearing a nick suffix + var re = new RegExp(user.alias + '.*$', 'i'); + settings.aliases[user.alias] = settings.aliases[user.alias].replace(re, user.alias); + set_alias(settings.aliases[user.alias]); + session.alias = settings.aliases[user.alias]; + } + break; + default: + if (typeof session[cmd[0]] == 'function') { + session[cmd[0]](cmd.slice(1).join(' ')); + } + break; + } + } else if (user_input == '\t') { // Nick completion + cmd = inputline.buffer.split(' ').pop().toLowerCase(); + var nick = ''; + session.nicks.some(function (e) { + if (e.substring(0, cmd.length).toLowerCase() != cmd) return false; + nick = e; + return true; + }); + if (nick != '') { + // lol this is horrible + // to do: add some methods to inputline + for (var n = 0; n < cmd.length; n++) { + console.ungetstr('\b'); + inputline.getkey(); + } + for (var n = 0; n < nick.length; n++) { + console.ungetstr(nick[n]); + inputline.getkey(); + } + } + } else if ( // Regular message + typeof user_input == 'string' // Could be bool at this point + && user_input != '' + && [KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT].indexOf(user_input) < 0 + ) { + session.send_room_message(user_input); + } + } else if (input_state == 'scroll' || input_state == 'scroll_nicks') { + var sframe = input_state == 'scroll' ? frames.output : frames.nicks; + // to do: page up, page down + if (user_input == KEY_UP && sframe.offset.y > 0) { + sframe.scroll(0, -1); + } else if (user_input == KEY_DOWN && sframe.offset.y + sframe.height < sframe.data_height) { + sframe.scroll(0, 1); + } else if (user_input == '' || user_input == 'q') { + frames.output.scrollTo(1, frames.output.data_height - frames.output.height); + frames.output_scroll.cycle(); + frames.divider.clear(); + frames.divider.gotoxy(frames.divider.width - 5, 1); + frames.divider.putmsg('/help'); + input_state = 'chat'; + } + } + } + if (frames.top.cycle()) { + frames.output_scroll.cycle(); + frames.nick_scroll.cycle(); + console.gotoxy(console.screen_columns, console.screen_rows); + } + yield(); + } + +} + +main(); diff --git a/xtrn/mrc/mrc-connector.example.ini b/xtrn/mrc/mrc-connector.example.ini new file mode 100644 index 0000000000000000000000000000000000000000..e256b30863ff7ad87d4ec0839958f690ed983eb1 --- /dev/null +++ b/xtrn/mrc/mrc-connector.example.ini @@ -0,0 +1,14 @@ +server = mrc.bottomlessabyss.net +port = 5000 +timeout = 10 +ping_interval = 60 +reconnect_delay = 30 + +[info] +;web = https://vurt.synchro.net:8443/ +;telnet = vurt.synchro.net:23 +;ssh = vurt.synchro.net:22 +;sysop = Bob Swindle +;description = Vurtrowen +;system_name = |17|11my custom |03bbs name +;platform = Not_Synchronet_6.6.6z/Amiga_OS/1.2.9 diff --git a/xtrn/mrc/mrc-connector.js b/xtrn/mrc/mrc-connector.js new file mode 100644 index 0000000000000000000000000000000000000000..750b0a1ab0902b63a6c31efe39b6c139e8f4936b --- /dev/null +++ b/xtrn/mrc/mrc-connector.js @@ -0,0 +1,278 @@ +/* $Id$ */ + +/** + * Multi Relay Chat connector (multiplexer) service + * echicken -at- bbs.electronicchicken.com + */ + +load('sbbsdefs.js'); +load('sockdefs.js'); + +if (!js.global.server) { + server = {}; + server.socket = new Socket(SOCK_STREAM, 'MRC_PROXY'); + server.socket.bind(5000, '127.0.0.1'); + server.socket.listen(); +} + +js.branch_limit = 0; +server.socket.nonblocking = true; + +var f = new File(js.exec_dir + 'mrc-connector.ini'); +f.open('r'); +const settings = f.iniGetObject(); +const system_info = f.iniGetObject('info') || {}; +f.close(); +f = undefined; + +const PROTOCOL_VERSION = '1.2.9'; +const MAX_LINE = 256; +const FROM_SITE = system.qwk_id.toLowerCase(); +const SYSTEM_NAME = system_info.system_name || system.name; + +const clients = {}; +var last_connect = 0; + +// User / site name must be ASCII 33-125, no MCI, 30 chars max, underscores +function sanitize_name(str) { + return str.replace( + /\s/g, '_' + ).replace( + /[^\x21-\x7D]|(\|\w\w)/g, '' // Non-printable & MCI + ).substr( + 0, 30 + ); +} + +// Room name must be ASCII 33-125, no MCI, 30 chars max +function sanitize_room(str) { + return str.replace(/[^\x21-\x7D]|(\|\w\w)/g, '').substr(0, 30); +} + +// Message text must be ASCII 32-125 +function sanitize_message(str) { + return str.replace(/[^\x20-\x7D]/g, ''); +} + +function strip_mci(str) { + return str.replace(/\|\w\w/g, ''); +} + +function parse_message(line) { + const msg = line.split('~'); + if (msg.length < 7) { + log(LOG_ERR, 'Invalid MRC line: ' + line); + 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] + }; +} + +function validate_message(msg) { + return ( + typeof msg == 'object' + && typeof msg.from_room == 'string' + && typeof msg.to_user == 'string' + && typeof msg.to_site == 'string' + && typeof msg.to_room == 'string' + && typeof msg.body == 'string' + ); +} + +function client_receive(c, no_log) { + const line = c.socket.recvline(1024, settings.timeout); + if (!line || line == '') return; + try { + if (!no_log) log(LOG_DEBUG, 'From client: ' + line); + const msg = JSON.parse(line); + return msg; + } catch (err) { + log(LOG_ERR, 'Invalid message from client: ' + line); + } +} + +function client_close(sock, message) { + sock.send(JSON.stringify(message) + '\n'); + sock.close(); +} + +function client_accept() { + if (!server.socket.poll()) return; + var c = server.socket.accept(); + if (!c) return; + c.nonblocking = true; + const msg = client_receive({ socket: c }, true); + if (!msg) return; + if (!msg.username || !msg.password) { + log(LOG_ERR, 'Invalid handshake from client: ' + JSON.stringify(msg)); + client_close(c, { error: 'Invalid handshake' }); + return; + } + const un = system.matchuser(msg.username); + if (!un) { + log(LOG_ERR, 'Invalid username from client: ' + msg.username); + client_close(c, { error: 'Invalid username' }); // Leaks user existence + return; + } + var u = new User(un); + if (msg.password != u.security.password) { + log(LOG_ERR, 'Invalid password for: ' + msg.username); + client_close(c, { error: 'Invalid password' }); + return; + } + clients[c.descriptor] = { + username: sanitize_name(msg.username), + socket : c, + ping: 0, + alias: msg.alias.replace(/\s/g, '_') + }; + c.sendline(JSON.stringify({ error: null })); +} + +// Forward a message to all applicable clients +function client_send(message, username) { + Object.keys(clients).forEach(function (e) { + if (!username || clients[e].username == username) { + log(LOG_DEBUG, 'Forwarding message to ' + clients[e].username); + clients[e].socket.sendline(JSON.stringify(message)); + } + }); +} + +function mrc_connect(host, port) { + if (time() - last_connect < settings.reconnect_delay) return false; + last_connect = time(); + const sock = new Socket(); + sock.nonblocking = true; + log(LOG_INFO, 'Connecting to ' + host + ':' + port); + if (!sock.connect(host, port, settings.timeout)) return false; + const platform = format( + 'Synchronet %s%s/%s/%s', + system.version, system.revision, system.platform, PROTOCOL_VERSION + ).replace(/\s/g, '_'); + const line = SYSTEM_NAME + '~' + (system_info.platform || platform); + log(LOG_DEBUG, 'To MRC: ' + line); + sock.send(line + '\n'); + while (sock.is_connected) { + yield(); + // Just waiting for the HELLO line; we could verify, but meh + if (!sock.data_waiting) continue; + log(LOG_DEBUG, 'From MRC: ' + sock.recvline(512, settings.timeout)); + break; + } + // Could check if HTTPS is available and if SSH is enabled (sbbs.ini) + mrc_send_info(sock, 'WEB', system_info.web || 'http://' + system.inet_addr + '/'); + mrc_send_info(sock, 'TEL', system_info.telnet || system.inet_addr); + mrc_send_info(sock, 'SSH', system_info.ssh || system.inet_addr); + mrc_send_info(sock, 'SYS', system_info.sysop || system.operator); + mrc_send_info(sock, 'DSC', system_info.description || system.name); + return sock; +} + +function mrc_send(sock, from_user, from_room, to_user, to_site, to_room, msg) { + if (!sock.is_connected) throw new Error('Not connected.'); + const m = sanitize_message(msg); + const line = [ + from_user, + FROM_SITE, + sanitize_room(from_room), + sanitize_name(to_user || ''), + sanitize_name(to_site || ''), + sanitize_room(to_room || ''), + m + ].join('~') + '~'; + log(LOG_DEBUG, 'To MRC: ' + line); + return sock.send(line + '\n'); +} + +function mrc_send_info(sock, field, str) { + mrc_send(sock, 'CLIENT', '', 'SERVER', 'ALL', '', 'INFO' + field + ':' + str); +} + +function mrc_receive(sock) { + var line, message; + while (sock.data_waiting) { + line = sock.recvline(MAX_LINE, settings.timeout); + if (!line || line == '') break; + log(LOG_DEBUG, 'From MRC: ' + line); + message = parse_message(line); + if (!message) continue; + if (message.from_user == 'SERVER' && message.body.toUpperCase() == 'PING') { + mrc_send(sock, 'CLIENT', '', 'SERVER', 'ALL', '', 'IMALIVE:' + SYSTEM_NAME); + return; + } + if (['', 'ALL', FROM_SITE].indexOf(message.to_site) > -1) { + if (['', 'CLIENT', 'ALL'].indexOf(message.to_site) > -1) { + // Forward to all clients + client_send(message); + } else { + // Send to this user + client_send(message, message.to_user); + } + } + yield(); + } +} + +function main() { + + var mrc_sock; + var die = false; + while (!die && !js.terminated) { + + yield(); + if (!mrc_sock || !mrc_sock.is_connected) { + mrc_sock = mrc_connect(settings.server, settings.port); + continue; + } + + client_accept(); + + Object.keys(clients).forEach(function (e, i) { + if (!clients[e].socket.is_connected) { + mrc_send(mrc_sock, clients[e].username, '', 'SERVER', '', '', 'LOGOFF'); + client_send({ from_user: clients[e].username, to_user: 'SERVER', body: 'LOGOFF' }); // Notify local clients + delete clients[e]; + } else { + var msg; + while (clients[e].socket.data_waiting) { + msg = client_receive(clients[e]); + if (!msg) break; + if (!validate_message(msg)) break; // Log + mrc_send( + mrc_sock, + clients[e].username, + msg.from_room, + msg.to_user, + msg.to_site, + msg.to_room, + msg.body + ); + yield(); + } + } + }); + + mrc_receive(mrc_sock); + + } + + log(LOG_INFO, 'Disconnecting from MRC'); + mrc_send(mrc_sock, 'CLIENT', '', 'SERVER', 'ALL', '', 'SHUTDOWN'); + const stime = time(); + while (mrc_sock.is_connected && time() - stime > settings.timeout) { + yield(); + } + if (mrc_sock.is_connected) mrc_sock.close(); + log(LOG_INFO, 'Disconnected. Exiting.'); + +} + +main(); diff --git a/xtrn/mrc/mrc-session.js b/xtrn/mrc/mrc-session.js new file mode 100644 index 0000000000000000000000000000000000000000..3299fe63870708b41c0301dce2b8c2b062eef98f --- /dev/null +++ b/xtrn/mrc/mrc-session.js @@ -0,0 +1,275 @@ +/* $Id$ */ + +// Passes traffic between an mrc-connector.js server and a client application +// See mrc-client.js for a bad example. +function MRC_Session(host, port, user, pass, alias) { + + const handle = new Socket(); + + const state = { + room: '', + room_topic: '', + nicks: [], + output_buffer: [], + last_ping: 0, + last_send: 0, + alias: alias || user + }; + + const callbacks = { + error: [] + }; + + function send(to_user, to_site, to_room, body) { + if (body == '' || body == state.alias) return; + state.output_buffer.push({ + from_room: state.room, + to_user: to_user, + to_site: to_site, + to_room: to_room, + body: body + }); + } + + function send_command(command, to_site) { + send('SERVER', to_site || '', state.room, command); + } + + function send_message(to_user, to_room, body) { + if (body.length + state.alias.length + 1 > 140) { + word_wrap(body, 140 - 1 - state.alias.length).split(/\n/).forEach(function (e) { + send(to_user, '', to_room, state.alias + ' ' + e); + }); + } else { + send(to_user, '', to_room, state.alias + ' ' + body); + } + } + + function emit() { + if (!Array.isArray(callbacks[arguments[0]])) return; + const rest = Array.prototype.slice.call(arguments, 1); + callbacks[arguments[0]].forEach(function (e) { + if (typeof e != 'function') return; + e.apply(null, rest); + }); + } + + function handle_message(msg) { + if (msg.from_user == 'SERVER') { + const params = msg.body.split(':'); + switch (params[0]) { + case 'BANNER': + emit('banner', params[1].replace(/^\s+/, '')); + break; + case 'ROOMTOPIC': + emit('topic', params[1], params.slice(2).join(' ')); + break; + case 'USERLIST': + state.nicks = params[1].split(','); + emit('nicks', state.nicks); + break; + default: + emit('message', msg); + break; + } + } else if (msg.to_user == 'SERVER') { + if (msg.body == 'LOGOFF') { + const uidx = state.nicks.indexOf(msg.from_user); + if (uidx > -1) { + state.nicks.splice(uidx, 1); + emit('nicks', state.nicks); + } + } + } else if (msg.to_user == '' || user.toLowerCase() == msg.to_user.toLowerCase()) { + if (msg.to_room == '' || msg.to_room == state.room) { + emit('message', msg); + } + if (msg.to_room == state.room + && state.nicks.indexOf(msg.from_user) < 0 + ) { + send_command('USERLIST', 'ALL'); + } + } else if (msg.to_user == 'NOTME') { + if (msg.body.search(/left\ the\ (room|server)\.*$/ > -1)) { + const udix = state.nicks.indexOf(msg.from_user); + if (uidx > -1) { + state.nicks.splice(uidx, 1); + emit('nicks', state.nicks); + } + } else if (msg.body.search(/just joined room/) > -1) { + send_command('USERLIST', 'ALL'); + } + } + } + + this.send_room_message = function (msg) { + send_message('', state.room, msg); + } + + this.send_private_messsage = function (user, msg) { + msg = '|11(|03Private|11)|07 ' + msg; + send_message(user, state.room, msg); + emit('sent_privmsg', user, msg); + } + + this.send_command = function (command, to_site) { + send_command(command, to_site || ''); + } + + this.connect = function () { + handle.connect(host || 'localhost', port || 50000); + handle.send(JSON.stringify({ + username: user, + password: pass, + alias: state.alias + }) + '\r\n'); + this.send_command('USERLIST', 'ALL'); + } + + this.disconnect = function () { + handle.close(); + } + + this.on = function (evt, callback) { + if (typeof callbacks[evt] == 'undefined') callbacks[evt] = []; + callbacks[evt].push(callback); + return callbacks[evt].length; + } + + this.off = function (evt, id) { + if (Array.isArray(callbacks[evt])) callbacks[evt][id] = null; + } + + this.cycle = function () { + var msg; + while (handle.data_waiting) { + try { + msg = JSON.parse(handle.recvline(), 1024, 10); + if (msg.body) handle_message(msg); + } catch (err) { + callbacks.error.forEach(function (e) { + e(err); + }); + } + } + if (time() - state.last_ping >= 60) { + this.send_command('IAMHERE'); + state.last_ping = time(); + } + if (state.output_buffer.length && time() - state.last_send >= 1) { + handle.send(JSON.stringify(state.output_buffer.shift()) + '\r\n'); + state.last_send = time(); + } + } + + const commands = { + banners: { + help: 'List of banners from server' + }, + chatters: { + help: 'List current users' + }, + connected: { + help: 'List connected BBSs' + }, + help: { + help: 'Display this help message', + callback: function (str) { + emit('help', 'List of available commands:'); + for (var c in commands) { + emit('help', c, commands[c].help, commands[c].ars); + } + emit('local_help'); + } + }, + info: { + help: 'View information about a BBS (/info #)', + callback: function (str) { + this.send_command('INFO ' + str); + } + }, + join: { + help: 'Move to a new room: /join room_name', + callback: function (str) { // validate valid room name? + str = str.replace(/^#/, ''); + this.send_command(format('NEWROOM:%s:%s', state.room, str)); + state.room = str; + state.nicks = []; + this.send_command('USERLIST', 'ALL'); + } + }, + meetups: { + help: 'Display information about upcoming meetups' + }, + motd: { + help: 'Display the Message of the Day' + }, + msg: { + help: 'Send a private message: /msg nick message goes here', + callback: function (str) { + const cmd = str.split(' '); + if (cmd.length > 1 && state.nicks.indexOf(cmd[0]) > -1) { + this.send_private_messsage(cmd[0], cmd.slice(1).join(' ')); + } + } + }, + notify: { + help: 'Send a notification message to the server (what?)', + callback: function (str) { + this.send_command('NOTIFY:' + str, 'ALL'); + } + }, + quit: { + help: 'Quit the program', + callback: function () { + handle.close(); + emit('disconnect'); + } + }, + rooms: { + help: 'List available rooms', + command: 'LIST' + }, + stats: { + help: 'Return anonymous server stats' + }, + topic: { + help: 'Change the topic of the current room', + callback: function (str) { + this.send_command(format('NEWTOPIC:%s:%s', state.room, str)); + } + }, + users: { + help: 'Display list of users' + }, + whoon: { + help: 'Display list of users and BBSs' + } + }; + + Object.keys(commands).forEach(function (e) { + if (commands[e].callback) { + this[e] = commands[e].callback; + } else if (commands[e].command) { + this[e] = function () { + this.send_command(commands[e].command.toUpperCase()); + } + } else { + this[e] = function () { + this.send_command(e.toUpperCase()); + } + } + }, this); + + Object.keys(state).forEach(function (e) { + Object.defineProperty(this, e, { + get: function () { + return state[e]; + }, + set: function (v) { + state[e] = v; + } + }); + }, this); + +} diff --git a/xtrn/mrc/readme.txt b/xtrn/mrc/readme.txt new file mode 100644 index 0000000000000000000000000000000000000000..e8eb3738fddd127e4efbf3d4a29900a7586d533e --- /dev/null +++ b/xtrn/mrc/readme.txt @@ -0,0 +1,82 @@ +Multi Relay Chat (MRC) client for Synchronet BBS 3.17b+ +echicken -at- bbs.electronicchicken.com + +1) Quick Start +2) Client -> Server -> Server +3) Customization +4) Support + + +1) Quick Start + +The following assumes that Synchronet is installed at /sbbs or c:\sbbs. Adjust +paths accordingly if your setup differs. + +- Copy mrc-connector.example.ini to mrc-connector.ini +- Copy mrc-client.example.ini to mrc-client.ini +- Edit '/sbbs/ctrl/services.ini' and paste in the following section: + + [MRC-Connector] + Port=5000 + Options=STATIC | LOOP + Command=../xtrn/mrc/mrc-connector.js + +- In SCFG, add a new External Program with the following options, leaving all + other options at their default values: + + Name: MRC + Internal Code: MRC + Start-up Directory: /sbbs/xtrn/mrc + Command Line: ?mrc-client.js + +- Your services and BBS threads should automatically recycle once they are free + to do so, but you may want to restart your BBS now to force these changes to + take effect. + + +2) Client -> Server -> Server + +MRC-Connector is a service that runs locally on your BBS. It maintains a +connection with a remote MRC server, and passes traffic between the remote +server and local clients. Local clients (users running mrc-client.js) connect +to MRC-Connector rather than connecting to the remote MRC server directly. + +You do not have to, and should not, open the MRC-Connector port to the outside +world. Clients are connecting to it from within your BBS, and in the typical +Synchronet configuration, that means they're coming from the same machine. + + +3) Customization + +Because MRC was originally a Mystic thing, you can use pipe codes wherever +colour codes are permitted: + + http://wiki.mysticbbs.com/doku.php?id=displaycodes#color_codes_pipe_colors + +I may write a CTRL-A to pipe converter at some later date. + +mrc-connector.ini: + + - You can adjust the 'server' and 'port' values if you wish to connect to + something other than the default MRC server. + - You can uncomment and edit the fields in the [info] section to alter what + information is shown about your BBS via the /info command. If you do not + uncomment these fields, defaults will be read from your system config. + - Spaces and colour codes are not allowed in the 'platform' value, and editing + this field is generally discouraged. The trailing MRC version number may be + critical. + +mrc-client.ini: + + - The 'server' and 'port' values dictate where mrc-client.js will connect to + MRC-Connector. The defaults should suffice unless you deviated from the + above instructions while editing '/sbbs/ctrl/services.ini'. + - The 'ping_interval' setting should be left at the default value unless you + have a good reason for changing it. + - The values in the [startup] section determine which room the client joins + on startup, and whether the Message of the Day and banners are displayed. + + +4) Support + +Nope. ;)