From a9b697daf0b4a02ae3f6e4ab9d82a1d9bf2c1255 Mon Sep 17 00:00:00 2001 From: corwin Date: Tue, 20 May 2025 22:58:05 +0200 Subject: [PATCH] put (partial) ES6 rewrite of comicchat somewhere This version has some good stuff, aside using ES6 classes which was fun and -years later- still looks pretty readable to me. Some details in the new org created to own this repo. --- classes.js | 1214 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1214 insertions(+) create mode 100644 classes.js diff --git a/classes.js b/classes.js new file mode 100644 index 0000000..858075d --- /dev/null +++ b/classes.js @@ -0,0 +1,1214 @@ +/* jslint node: true */ + +'use strict'; + +const fs = require('fs'); +var net = require('net'); +var tls = require('tls'); +var WebSocketClient = require('websocket').client; + +function log(msg) { + console.log(msg); +} + +function debug(msg) { + console.log(msg); +} + +class Arg { + // Arg.Make("field") == Arg.Make("field", "_field") + // == Arg.Make({name: "field", slot: "_field"}) + // == Arg.Make(new Arg("field")) ;; etc + static Make(field, slot=null) { + if(field instanceof Arg) { + return field} + if(typeof field === 'string' || field instanceof String) { + return new Arg(field, slot)} + if(field.hasOwnProperty('name')) { + return new Arg(field, slot)} + throw new Error('[[Error:[source:'+Arg.Make + +']][type:[invalid]][invalid:[' + +field+']]]');} + + // "x" == Arg.Find(new Arg("field"), {field:"x"}) + // == Arg.Find("foo.field", {foo:{field:"x"}}) + // == Arg.Find(new Arg("field"), {field:"y"}, {field:"x"}) + static Find(field, config={}, args=null) { + var obj = Arg.Make(field); + if(args && args[obj.Arg]) { + return args[obj.Arg];} + var arg = obj._name; + if(arg) { + var dotIX = arg.indexOf('.'); + if(dotIX != -1) { + if(dotIX +1 < arg.length) { + var first = arg.substring(0, dotIX); + var rest = arg.substring(dotIX+1,arg.length); + if(config[first]) { + return Arg.Find( + new Arg({name:rest}), + config[first]);}}} + else if(config[arg]) { + return config[arg];}}} + // mutates object + static Set(object, field, value) { + object[Arg.Make(field).Slot] = value;} + static OPT(obj, path, fallback) { + if(!(obj && path)) return fallback; + var c = obj; + var parts = path.split('.'); + for(p in parts) { + c = c[p]; + if (!c) return fallback; } + return c[parts[parts.length-1]] || fallback; } + + constructor(name, slot=null) { + if(!name) { + throw new Error('[[Error:[source:Arg.new]]' + +'[type:[missing]][missing:' + +'[name]]]')} + this._name = name.name ? name.name : name; + if(slot) this._slot = slot; + else if(name.slot) this._slot = name.slot; + if(name.arg) this._arg = name.arg;} + get Arg() { + return this._arg || this.Name } + get Name() { + return this._name.indexOf('.') === -1 + ? this._name + : this._name.substring( + this._name.lastIndexOf('.')+1, + this._name.length);} + get Slot() { return this._slot || '_' + this.Name }} + +class File { + static IsFile(obj) { + return obj && obj instanceof File; } + static Make(file, slot=null, path=null) { + if(File.IsFile(file)) { + return file; } + else if(typeof file === 'string' || file instanceof String) { + return new File(file, slot, path); } + else if(file.hasOwnProperty('name')) { + return new File(file, slot, path); } + throw new Error('[[Error:[source:File.Make' + +']][type:[invalid]][invalid:[' + +file+']]]')} + constructor(name, slot=null, path=null) { + if(name.arg) this._arg = name.arg; + this._name = name.name || name.arg || name; + this._slot = name.slot || slot; + if(name.path || path) { + if(name.path) { + path = name.path; } + if(path) { + if(Array.isArray(path)) { + path = path.join('/')} + path = path.split('/').filter( + x => x && x.length > 0 + && x.indexOf('.') === -1); + if(path && path.length > 0) { + this._path = path;}}}} + get Arg() { return this._arg || this.Name; } + get Name() { return this._name; } + get Slot() { return this._slot; } + get Path() { // list of parts (or false when no path) + return this._path && this._path.length + ? [...this._path] + : false; } + get Full() { return (this.Path||[]).concat([this.Name])}} + +class ComicChat { + static get BasePath() { return process.env.COMIC_CHAT_PATH || '.' } + static get ConfigName() { return 'comicchat' } + static get ConfigExt() { return 'json' } + static get DefaultProgramStrings() { + return { + version: 'UNKNOWN', program: 'none', + connect: true, ssl: true, + cchat : { host: 'localhost', port: '8021', sock: 'wss' }}} + static get Fields() { return [ + {name:'program'}, {name:'version'}, + {name: 'config', slot:'_configFile'}, + 'connect', 'ssl', 'cchat.host', 'cchat.port', + {name:'cchat.sock', arg: 'websocket'}]; } + static get ProgramArgs() { return require('minimist')(process.argv.slice(2)); } + + static IsComicChatObject(obj) { + return obj && obj instanceof ComicChat; } + static IsSafeString(s) { + return (typeof s === 'string' || s instanceof String) + && s.length > 0 + && !/[^a-zA-Z0-9_-]/.test(s);} + static FilePath(name) { + if(Array.isArray(name)) { + if(name.every(ComicChat.IsSafeString)) { + name = name.join('/');} + else { + throw new Error('[[Error:[source:'+ComicChat.FilePath + +']][type:[invalid]][invalid:[' + +name.join('/')+']]]')}} + else if(! ComicChat.IsSafeString(name)) { + throw new Error('[[Error:[source:'+ComicChat.FilePath + +']][type:[invalid]][invalid:[' + +name+']]]');} + return ComicChat.BasePath + + '/' + name + + '.' + ComicChat.ConfigExt;} + static ParseConstructorArgs(defaults={}, options=false) { + return options === false + ? [ComicChat.DefaultProgramStrings, defaults] + : [{...ComicChat.DefaultProgramStrings, ...defaults}, options];} + static ReadFile(name, throwOnError=true){ + var rv = {}; + try { + rv = JSON.parse( + fs.readFileSync( + ComicChat.FilePath( + File.Make(name).Full)))} + catch(e) { + log('caught error reading ' + + name + ': ' + e + + (throwOnError?' (rethrowing)':'')); + if(throwOnError) { + throw e;}} + return rv;} + static SelectConfig(args, defaultFile=false) { + var configFile = []; + if(ComicChat.IsSafeString(args[1])) { + configFile = [args[1]]; + args[1] = {config: [args[1]]};} + else if(Array.isArray(args[1])) { + configFile = args[1]; + args[1] = {config: configFile};} + else if(args[1].config === false) { + configFile = [];} + else if(ComicChat.IsSafeString(args[1].config)) { + configFile = [args[1].config];} + else if(Array.isArray(args[1].config) + && [...args[1].config + ].every(ComicChat.IsSafeString)) { + configFile = args[1].config;} + else if(defaultFile !== false) { + configFile = [ defaultFile === true + ? ComicChat.ConfigName + : defaultFile];} + return configFile;} + static Version(obj) { + var rv = + obj instanceof ComicChat + ? { program: obj._program, version: obj._version } + : { program: ComicChat.DefaultProgramStrings.program, + version: ComicChat.DefaultProgramStrings.version }; + return `${rv.program} at version: ${rv.version}`;} + static Connect(host, port, handler) { + const socket = new net.Socket(); + socket.on('connect', handler); + socket.connect(port, host); + return socket;} + static ConnectSSL(host, port, handler) { + const socket = tls.connect(port, host); + socket.on('secureConnect', handler); + return socket; } + + constructor(defaults={},options=false) { + this._ready = false; + this._version = ComicChat.DefaultProgramStrings.version; + this._program = ComicChat.DefaultProgramStrings.program; + + var input = ComicChat.ParseConstructorArgs(defaults, options); + ComicChat.SelectConfig(input).map(function(f) { + var o = File.Make(f); + if(o.Slot) { + input[1][f.slot] = ComicChat.ReadFile(o);} + else { + input[1] = { ...input[1], ...ComicChat.ReadFile(o)};} + }.bind(this)); + if(!this.dispatcher) { + this.dispatcher = new DataHandler(); } + this.config = {...input[0], ...input[1]}; + this.init(); + this.test(); + if(this.Ready && this._connect) { + this.Connect(); }} + init() { + this.Fields.map(function(f) { + var a = new Arg(f); + var v = Arg.Find(a, this.config, ComicChat.ProgramArgs); + if(v) { Arg.Set( this, a, v); } + }.bind(this));} + test() { + if(! ComicChat.IsSafeString(this.Program)) { + log('unsafe program name: ' + this.Program);} + else if(this.Program == ComicChat.DefaultProgramStrings.program) { + log('a program has no name');} + else { + return this._ready = true;} + return this._ready = false;} + get Fields() { return ComicChat.Fields; } + get Program() { return this._program; } + get Ready() { return this._ready === true; } + get Version() { return ComicChat.Version(this); } + get Host() { return this._host; } + get Port() { return this._port; } + get WebsocketProtocol() { return this._sock; } + + createSocket(host=this.Host, + port=this.Port, + handler=this.HandleConnect) { + var socket; + var cb = function() { + socket.on('error', log); + socket.on('data', this.HandleData.bind(this)); + }.bind(this); + if (this._ssl === true) { + if(this._selfsigned === true) { + // enable self-signed certificates + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; } + socket = ComicChat.ConnectSSL(host, port, handler); } + else { + socket = ComicChat.Connect(host, port, handler); } + socket.setEncoding(this._encoding); + if(this.nodelay === true) { + socket.setNoDelay();} + return socket; } + Connect(throwOnError=true) { + try { socket = this.createSocket(); } + catch(e) { + if(throwOnError === true) { + throw new Error('TODO connect error' + e); }} + this.socket = socket; + return this.socket; } + on(data, callback, once=false) { + this.dispatcher.on(data, callback, once); } + on_once(data, callback) { this.on(data, callback, true)} + raw(data) { + data && this.socket.write( + data + '\n', this._encoding); } + + HandleData(message) { this.dispatcher.dispatch(message); } + HandleConnect(args) { + log(args); + const listeners = this.listeners || []; + this.socket.on('data', this.HandleData.bind(this));}} + +class ComicChatPublisher extends ComicChat { + static get Fields() { + return ComicChat.Fields.concat( + 'nick', 'room', 'link', + {name:'irc.channels', + slot:'_rooms', + arg:'rooms'}); } + static get defaultOptions() { + return { + nick: 'petromeglyph', + room: '#dungeon', + link: 'http'}; } + + constructor(defaults={},options=false) { + if(options === false) { + options = defaults; + defaults = ComicChatPublisher.defaultOptions; } + else { + defaults = {...ComicChatPublisher.defaultOptions, + ...defaults}; } + super(defaults, options); } + get Fields() { return ComicChatPublisher.Fields } + get Nick() { return this._nick; } + set Nick(na) { this._nick = na; } + get Room() { return this._room; } + set Room(rm) { this._room = rm; } + MakeLink(rm=this.Room) { + return this._link + '://' + + this.Host + ':' + this.Port + + '/' + rm; } + get Link() { return this.MakeLink() }} + +class Message { + constructor(data) { this._data = data; } + data() { return this._data; } + reduce() { return this._data ? this._data.toString() : ''; } +} + +class DataParser { + static Is(obj) { return obj && obj instanceof DataParser; } + static Make(obj) { + return Is(obj, Class=DataParser) ? obj : new Class(obj); } + static MakeMessageParser(Class=Message) { + return function(d) { return new Class(d); }} + *[Symbol.iterator]() { + const vals = [ this._parser, this._handler, this._once ]; + for(v in vals) { + ++count; + yield v; } + return count; } + constructor(parser=null, handler=null, once=null) { + var options = parser && parser.hasOwnProperty('parser') + ? {...parser } + : (parser && Array.isArray(parser) + ? { parser: parser[0], + handler: parser[1], + once: parser[2]} + : { parser: parser, + handler: handler, + once: once}); + if(options.parser && options.parser === true) { + options.parser = DataParser.MakeMessageParser( + options.parserClass || Message); } + if(options.parser) this._parser = options.parser; + if(options.handler) this._handler = options.handler; + if(options.once) this._once = true;}} + +class DataHandler { + constructor(handlers=[], context=null) { + this._context = context; + this._handlers = [...handlers]} + dispatch(data, context=this._context) { + for (var i = 0; i < this._handlers.length; i++) { + const info = this._handlers[i][0](data); + if (info) { + this._handlers[i][1].apply(context, [info, data]); + if (this._handlers[i][2]) { + this._handlers.splice(i, 1); }}}} + on(parser, handler, once=false) { + this._handlers.push([parser, handler, once]); } + on_once(parser, handler) { + this.on(parser, handler, true); }} + +class IRCMessage extends Message { + static Is(obj) { + return obj && obj instanceof IRCMessage; } + static Make(data) { + return IRCMessage.Is(obj) ? obj + : new IRCMessage(data); } + *[Symbol.iterator]() { + var count = 0; + for(data in this.data) { + ++count; + yield data; } + return count; } + constructor(data) { super(this.parse(data)) } + parse(data=this.data) { + if(!Array.IsArray(data)){ data = [data]; } + return data.join("\n").split("\n").filter( + s => s + && s.length > 0 + && (!s.indexOf("\n")))}} + +class IRCDataHandler extends DataHandler { + on(parser, handler, once=false) { + super.on(data => parser.exec(data), + handler, once); } + dispatch(data) { + const msg = IRCMessage.Make(data); + for(var line in msg) { + super.disptch(line); }}} + +class MessageListener { + static Is(obj) { + return obj && obj instanceof MessageListener; } + static Make(obj) { + return Is(obj) ? obj + : new MessageListener(obj); } + static Subscribe(thing, listeners, factory=MessageListener.Make) { + if(thing && thing.on) { + return listeners.map( + me => factory(me).on(thing))}} + static get NullHandler() { + return new Function(); } + + // this._type = 'data'; + // this._handler = MessageListener.NullHandler; + constructor(type, handler=MessageListener.NullHandler) { + const options = type && type.hasOwnProperty('type') + ? { ...type } + : { type: type, handler: handler }; + this._type = options.type || 'data' + if(options.handler) { + this._handler = options.handler; }} + get handler() { return this._handler; } + get type() { return this._type; } + on(thing) { + if(thing && thing.on + && (typeof thing.on === 'function' + || thing.on instanceof Function)) { + thing.on(this.type, this.handler); }}} + +// class SocketListener { +// static Connect(host, port, handler) { +// const socket = new net.Socket(); +// socket.on('connect', handler); +// socket.connect(port, host); +// return socket;} +// static ConnectSSL(host, port, handler) { +// const socket = tls.connect(port, host); +// socket.on('secureConnect', handler); +// return socket; } +// static get DefaultHandler() { +// return new Function(); } +// static get DefaultListeners() { +// return //['data','close','error']; +// [ +// { type: 'data', +// handler: function(data) { this.HandleData(data) }}, +// { type: 'connect', +// handler: function(socket) { +// this.Subscribe(socket, +// this.listeners.filter( +// x => x && x.type +// && x.type != 'connect')); }}]} + +// get SSL() { return SocketListener.ConnectSSL; } +// get NonSSL { return SocketListener.Connect; } + +// createSocket(host=this._host, +// port=this._port, +// handler=this.Subscribe) { +// if(this.socket) { //&& punt.howToCheckIfNeedNew) { +// return this.socket; } + +// var socket; +// if (this._ssl === true) { +// if(this._selfsigned === true) { +// // enable self-signed certificates +// process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; } +// socket = this.SSL(host, port, handler); } +// else { +// socket = this.NonSSL(host, port, handler); } +// socket.setEncoding(this._encoding); +// if(this.nodelay === true) { +// socket.setNoDelay();} +// return socket; } +// connect(throwOnError=true) { +// // TODO: check for live connection? +// try { +// const socket = this.createSocket(); +// this.socket = socket;} +// catch(e) { +// if(throwOnError === true) { +// throw new Error('TODO connect error' + e); }} +// return this.socket; } + +// // this.dispatcher = new DataHandler(); +// HandleData(message) { this.dispatcher.dispatch(message); } +// //this.listeners = SocketListener.DefaultListeners; +// Subscribe(socket, listeners=this.listeners) { +// MessageListener.Subscribe(socket, listeners); } + +// constructor(options={}) { +// this.dispatcher = new DataHandler(); +// this.listeners = SocketListener.DefaultListeners; +// //this.#_host; +// // this.#_nodelay = true; +// // this.#_encoding = 'utf-8'; +// // this.#_port; +// // this.#_selfsigned = true; +// // this.#_ssl = true; +// //this.socket; +// if(!options) { +// options = {}; } +// else if(options.connect +// && (typeof options.connect === 'function' +// || options.connect instanceof Function)) { +// // single arg constructor call contains socket +// options = {socket:options}; } +// if(options.hasOwnProperty('ssl')) { +// this._ssl = options.ssl; } +// if(options.hasOwnProperty('selfsigned')) { +// this._selfsigned = options.selfsigned; } +// if(options.hasOwnProperty('nodelay')) { +// this._nodelay = options.nodelay; } +// if(options.hasOwnProperty('encoding')) { +// this._encoding = options.encoding; } + +// if(options.socket) { +// this.socket = options.socket; } +// if(options.hasOwnProperty('connect') +// && !options.connect) { +// } //noop +// else { this.connect(); }} +// on(data, callback, once=false) { +// this.dispatcher.on(data, callback, once); } +// on_once(data, callback) { this.on(data, callback, true)} + +// raw(data) { +// data && this.socket.write( +// data + '\n', this._encoding); }} + +// START TESTING + +var cc = new ComicChatPublisher({config: 'test', //['test',{slot:'test2',file:"test"}], + test:"OK", version:"1.0.1"}); +// {config: 'test3', test:"OK", version:"1.0.1",program:"test3"} + cc.on(function() { return {}; }, function(...args) { log(args) }) + console.log({program:cc.Program,ready:cc.Ready,obj:cc,link:cc.Link}); + +// START UNTESTED + +// var wsConnection, wsRetryHandlerID = null; +// var wsopConnection, wsopRetryHandlerID = null; + +const owners = + [ { nick: "corwin", flags: { op: true }, + mask: "corwin!someone@fosshost/director/corwin"} ]; + +const commands = [ 'join', 'part', 'set', 'op', 'hup', 'version' ]; + + +class ComicChatAuthenticatedClient { + static get maxSendHistory() { return 5 } + static get DefaultCerts() { return function(certPath) { + return { + ca: fs.readFileSync(`${certPath}/ca-crt.pem`), + key: fs.readFileSync(`${certPath}/client1-key.pem`), + cert: fs.readFileSync(`${certPath}/client1-crt.pem`), + requestCert: true, + rejectUnauthorized: true + }}(config.control.certs||'.') } + static get DefaultOptions() { return { + ca: "", + key: "", + cert: "", + host: 'server.localhost', + port: config.control.port || 8020, + rejectUnauthorized:true, + requestCert:true + }} + constructor(options) { + this.sendCount = 0; + this.sendQueue = [{serial:0,message:{type:'version'}, + callback:function(){ + log('AC<-version: ' + JSON.parse(m))} + }]; + this.responseQueue = {}; + this.config = { ...ComicChatAuthenticatedClient.DefaultOptions, ...options}; + if(!this.config.ca) { + this.config = { ...this.config, ...ComicChatAuthenticatedClient.DefaultCerts}; + } + this.socket = this.connect(); + this.on('data', (data) => { + log('AC DATA: ' + data); + var replyJSON; + try { + replyJSON = JSON.parse( data); + if(! replyJSON && replyJSON.serial + && Number.isInteger( replyJSON.serial)) { + throw new Error('reply serialization ' + + '(nothing to deque)') + } + + const serial = replyJSON.serial; + const message = sendQueue[ serial]; + if(! (message && serial == message.serial)) { + throw new Error('reply serialization (not found)') + } + if(! this.responseQueue[ serial ]) { + this.responseQueue[serial] = {sent:[message]}; + } else { + this.responseQueue[serial].sent.push(message); + if(this.responseQueue[serial].sent.length > + ComicChatAuthenticatedClient.maxSendHistory) { + this.responseQueue[serial].sent.splice( + this.responseQueue[serial].sent.length -1, 1) + } + } + this.responseQueue[serial].reply = replyJSON; + + // don't remove first entry + if( serial > 0) { + //this.sendQueue.splice( serial, 1); + this.sendQueue[ serial] = null; + } + + if(message.callback) { + message.callback( replyJSON ); + } + } catch (e) { + log('AC (error) Bad message ' + data); + log('AC (Error detail): ' + e); + return; + } + }); + this.on('error', (error) => { console.log('AC error: ' + error)}); + this.on('end', (data) => { console.log('AC Socket end event')}); + } + connect(options=this.config) { + const socket = tls.connect(options, (c) => { + console.log('client connected', + socket.authorized ? 'authorized' : 'unauthorized'); + console.log(c) + socket.write('{"type":"version"}'); + process.stdin.pipe(socket); + process.stdin.resume(); + }); + socket.setEncoding('utf8'); + socket.setNoDelay(true); + socket.setKeepAlive(true, 5000); + return socket; + } + on(sig,cb) { + this.socket.on(sig, cb); + } + + send(message, callback) { + if(! this.socket) { + log('AS (send error) : no socket'); + } + + if(! messaage) { + // ZZ blank message handlong? + log('AS (send error) : no message'); + } + + if(! message.sendQueueSerial ) { + message.sendQueueSerial = ++this.sendCount; + sendQueue[ message.sendQueueSerial ] = { + message:message, + serial: message.sendQueueSerial, + callback:callback + }; + //} else if (somehow.WeSend(SomethingElse).FirstInstead) { + //message = SomethingElse + } else { + // it's a retry + } + socket.write(JSON.stringify(sendQueue[ message.sendQueueSerial])); + } +} + +// // make controler connection (New! Experimental!) +// var controller = function () { +// var host = config.control.host || 'localhost'; +// var port = config.control.port || 8020; +// return new ComicChatAuthenticatedClient({ +// host: host, +// port: port +// })}(); + + +class ComicChatIRCRelay { + static get defaultOwners() { + return [ + { nick: "corwin", flags: { op: true }, + mask: "corwin!someone@fosshost/director/corwin" + }];} + + static get defaultCommands() { + return [ + 'join', 'part', 'set', 'op', 'hup', 'version' + ];} + + static get defautOptions() { + return { + cchat: { + nick: 'petroglyph', + room: '#dungeon', + host: 'localhost', // hidoi.moebros.org + port: 8081, + roomLink: 'http://localhost/#dungeon' + }, + control: { + port: 8082, + host: 'edit.comic.chat', + certs: 'cert' + }, + irc: { + nick: 'petromeglpyh', + user: 'relay', + real: 'comicchat', + channels: ['#comicchat'], + host: 'irc.libera.chat', + emitGreeting: false, + port: 6697, + ssl: true, + opers: owners, + oppre: null, + nicre: null, + cmds: commands, + cmdre: null, + }, + }; + } + constructor(options=defaultOptions) { + this._irc = null; + this._control = null; + this._irc = false; + this.config={...defaultOptions, options}; + } + + //log(text) +} + +module.exports = { Arg: Arg, File: File }; + +/* +process.title = 'petroglyph'; + +function log (text) { + console.log("\n" + (new Date()) + "\n" + text); +} +const warn = (text) => log(text); +const err = (text) => log(text); + +var net = require('net'); +var tls = require('tls'); +var WebSocketClient = require('websocket').client; + +const fs = require('fs'); + +var args = require('minimist')(process.argv.slice(2)); +const confPath = args.path || '.'; +const confFile = args.conf || 'relay'; +const defaultConfig = { + cchat: { + nick: 'petroglyph', + room: '#dungeon', + host: 'ws.comic.chat', // hidoi.moebros.org + port: 8021, + roomLink: 'http://ws.comic.chat/#dungeon' + }, + control: { + port: 8022, + host: 'edit.comic.chat', + certs: '/git/comicchat-edit/cert' + }, + irc: { + nick: 'petromeglpyh', + user: 'comic', + real: 'relay++beta', + channels: ['#dungeon', + '#comicchat', '#c2e2', + // '#fosshost', '#fosshost-social', + "##bigfoss", "##moshpit", "##apocalypse" + ], + host: 'irc.libera.chat', + emitGreeting: false, + port: 6697, + ssl: true, + opers: owners, + oppre: null, + nicre: null, + cmds: commands, + cmdre: null, + }, +}; + +//const dataPath = args.data || 'data/'; +//const dataFiles = ["channel-ops"]; +//const defaultData { "channel-ops": {} }; + +{ + var _loadFile = function (path, ext, config, file) { + var v = {}; + if(file.test(/[^a-z0-9_-]/)) { + //throw new Error("can't load conf (bad-filename " + file); + err("(Relay Startup) can't load conf (bad-filename) '" + file +"'"); + } else { + const filePath = `${path}/${file}${ext}`; + try { v = JSON.parse(fs.readFileSync(filePath))} + catch (e) { v = {} } + } + return { ...config, ...v }; + } + const loadConf = _loadFile.bind(null, confPath, ".json", defaultConfig, configFile); + // const loadFile = function(file) { + // if(dataFiles.indexOf(file) != -1) { + // const defaultConfig = defaultData[file] || {}; + // return _loadFile(dataPath, ".json", defaultConfig, file); + // } + // warn("(relay) attempt to load unknown data-file '"+file+"'"); + // return {}; + // } +} +const config = loadConf(); + +// ty: https://medium.com/stackfame/how-to-run-shell-script-file-or-command-using-nodejs-b9f2455cb6b7 +const util = require('util'); +const exec = util.promisify(require('child_process').exec); +async function restartThisBot(irc, oper) { + try { + const { stdout, stderr } = + await exec('/git/petroglyph/start-relay+.sh'); + //console.log('stdout:', stdout); + //console.log('stderr:', stderr); + }catch (err) { + console.error(`*** FAIL restarting (${oper}):` + + JSON.stringify(err)); + reply(irc, oper, `restarted failed: ${err}`); + }; +}; + +// https://stackoverflow.com/a/3561711 +function escapeRegex(s) { + return new String(s).replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); +} + +const stripHostRE = new RegExp('^[@!]+'); +// pass object or makes one from from nick+mask string + +function nick2mask(nick) { + var o = Array.isArray(nick) ? nick : { + nick: nick, flags: {}, mask: "" + }; + if(o && o.nick && o.nick != '') { + if(-1 == o.nick.indexOf( '@')) { + mask = '(?:'+str+'![^@]![^ ]+*?)'; + } else if(matches = stripHostRE.exec(o.nick)) { + mask = o.nick; + o.nick = matches[0]; + } + if(o && o.nick && o.nick !='' // object, contains nick + && -1 === o.nick.indexOf('@') // without junk + && -1 !== o.mask.indexOf('@')) // and mask with + return o; + } + return null; +} + +function initOper(oper) { + var o = nick2mask(oper); + var oix = oper ? config.irc.oper.findIndex( o => o.nick == oper.nick ) : -1; + if(-1 !== oix) { + if(o)config.irc.opers[ oix ] = o; + else config.irc.opers.splice( oix, 1); + } else if(o) config.irc.opers.push( o); + return o; +} + +function initOperConfig() { + //log( 'Config: ' + JSON.stringify( config)); + config.irc.oppre = new RegExp('^:(?:'+(config.irc.opers.map(escapeRegex).join('|'))+')'); + config.irc.pmpre = new RegExp('^:[^:]+PRIVMSG ' + escapeRegex(config.irc.nick) + ' :'); + config.irc.cmdre = new RegExp('^('+(config.irc.cmds.join('|'))+')'); + log("loaded command RE: " + config.irc.cmdre.toString()); +} + +function joinChannel(irc, channel, message) { + irc.raw('JOIN ' + channel); + if(true === config.cchat.emitGreeting) + reply(irc, channel, (message ? message + : 'Relaying to: ' + + config.cchat.roomLink + .replace(config.cchat.room, channel))); +} + +function reply(irc, who, message) { + irc.raw('PRIVMSG ' + who + ' :' + message); +} + +function parseOp(irc, who, op, value) { + var args = value.split(' '); + var command = args.splice(0,1)[0]; + var thisOP = {irc:irc,who:who,op:op,value:value, + args:args,command:command}; + if(! config.irc.cmds.includes( thisOP.command )) { + log("OP (error): unknown command '" + + thisOP.command + + " for " + thisOP.who + + " (" + thisOP.value +")" + ); + reply(irc, who, 'Huh? what is "' + command + '"?'); + return; + } + + switch(thisOP.command) { + case "hup": + restartThisBot(thisOP.irc,thisOP.who); + break; + case "part": + thisOP.channel = thisOP.args[0]; + if(!thisOP.channel) { + log("OP (error): cannot part '" + thisOP.channel + " for " + + thisOP.who + ": " + thisOP.value); + reply(thisOP.irc, thisOP.who, + 'Hrmm, try "part #foo" (?!?: ' + + thisOP.channel + ')' + ); + } else if(!config.irc.channels.includes( thisOP.channel)) { + log("OP (warning): part from unknown channel '" + + thisOP.channel + " from " + thisOP.who); + reply(thisOP.irc, thisOP.who, + "I'm not on " + thisOP.channel + + ", " + thisOP.who); + } else { + log("OP *part* " + thisOP.channel + " (" + thisOP.who + ")"); + var ix = config.irc.channels.indexOf(thisOP.channel); + config.irc.channels.splice(ix, 1); + comicchatServerJoin(thisOP.channel, true); + irc.raw('PART ' + thisOP.channel); + } + break; + case "version": + var onControlReply = function(data) { + log('OP <- AP DATA: ' + data); + reply(thisOP.irc, thisOP.who, "response: " + data); + }; + break; + case "join": + var channel = thisOP.args[0]; + if(channel && channel.length && channel.indexOf('#') === 0) { + if(config.irc.channels.includes(channel)) { + log("OP (warning): duplicated join to '" + + channel + " from " + thisOP.who); + reply(thisOP.irc, thisOP.who, "Already on " + channel); + } else { + log("OP *join* " + channel + " (" +who+ ")"); + comicchatServerJoin(channel); + config.irc.channels.push(channel); + joinChannel(irc, channel); // blindly assuming this worked. yay. + reply(irc, who, "OK. I joined " + channel); + } + } else { + log("OP (error): cannot join '" + channel + " for " + + who + ": " + value); + reply(irc, who, 'Hrmm, try "join #foo" (?!?: ' + channel + ')'); + } + break; + default: + log("OP (warning): unknown command '" + command + + "' from " + who + " (full: " +value+ ")"); + reply(irc, who, "unkown command '" + commands[0] + "'"); + } +} + +initOperConfig(); + +// COMIC CHAT OPER FUNCTIONS + +function isOper(op) { + // log('Op -> isOper? ' + // + JSON.stringify({op:op, isArray:Array.isArray(op), + // test: config.irc.oppre.test( op[0]), + // includes: config.irc.opers.includes( op), + // })); + return Array.isArray( op ) // called with "info"? + ? config.irc.oppre.test( op[0]) + : config.irc.opers.includes( op); +} + +function isPM(info) { + // log('Op -> isPM? ' + // + JSON.stringify({info:info, botnick:config.irc.nick, + // pmpre:config.irc.pmpre.toString(), + // test:config.irc.pmpre.test( info[0]) + // })); + return config.irc.pmpre.test( info[0]); +} + +function setControl(irc, who, op, value) { + if (wsopConnection && wsopConnection.send) { + log('Op -> Set "' + + op + '" => ' + JSON.stringify( value) + //+ JSON.stringify({who:who, op:op, value:value}) + ); + wsopConnection.send(JSON.stringify({ + type: 'set', + control: op, + settings: value.split(' '), + author: who + })); + } else { + log('IRC->Op Problem with Op connection, not setting'); + } +} + +function sendMessage(info) { + if (wsConnection && wsConnection.send) { + //log('CC -> RELAY ' + info[1] + ': ' + info[2]); + log('CC -> RELAY (info) ' + JSON.stringify(info)); + wsConnection.send(JSON.stringify({ + type: 'message', + room: info[3] || config.cchat.room, + text: info[2], + author: info[1], + spoof: true + })); + } else { + log('IRC->CC Problem with CC connection, not relaying'); + } +} + +// COMIC CHAT CONNECTION +var comicchatServerJoin; +function makeComicChat () { + var reconnectFunction = function () { + if (wsRetryHandlerID === null) { + wsRetryHandlerID = setInterval(function () { + log('CC: Reconnecting...'); + makeComicChat(); + }, 10000); + } + }; + + function addHandlers (ws) { + ws.on('connect', function (connection) { + log('CC: Websocket client connected to comic chat.'); + wsConnection = connection; + if (wsRetryHandlerID !== null) { + clearInterval(wsRetryHandlerID); + wsRetryHandlerID = null; + } + + // Join room, announce to room that relay has joined. + comicchatServerJoin = function(channel, part) { + (part ? + [ + { type: 'message', room: channel, text: 'Fin.' }, + { type: 'part', room: channel } + ] : [ + { type: 'join', room: channel }, + { type: 'message', room: channel, text: config.cchat.nick }, + { type: 'message', room: channel, + author: config.cchat.nick, + text: 'Hello everyone! ' + config.irc.nick + ' ' + channel +' messenger here.' } + ]).forEach(function (message) { + connection.sendUTF(JSON.stringify(message)); + }); + } + config.irc.channels.forEach((channel) => comicchatServerJoin(channel)); + + connection.on('error', function (e) { + log('CC: Connection error', e); + reconnectFunction(); + }); + + connection.on('close', function (e) { + log('CC: Connection closed', e); + reconnectFunction(); + }); + }); + + return ws; + } + + var ws = addHandlers(new WebSocketClient()); + ws.on('connectFailed', function (e) { + log('CC: Conenction failed', e); + reconnectFunction(); + }); + ws.connect('wss://' + config.cchat.host + ':' + config.cchat.port); +} + +// COMIC CHAT CONTROLLER CONNECTION +makeComicChat(); + +// IRC CONNECTION + +var irc = {}; +irc.listeners = []; +irc.pingTimerID = null; +function makeIRC() { + var connectHandler = function () { + log('IRC: established connection, registering...'); + + irc.on(/^PING :(.+)$/i, function (info) { + irc.raw('PONG :' + info[1]); + }); + + irc.on(/^.+ 001 .+$/i, function () { + config.irc.channels.forEach(function(channel) { + joinChannel(irc, channel); + }); + }); + + irc.on(/^:(.+)!.+@.+ PRIVMSG .+? :(.+)$/i, function(info) { + log('IRC -> PIRVMSG ' + JSON.stringify({oper:isOper(info)?true:false, + pm:isPM(info)?true:false, + info:info + })); + if(isPM(info)) { + if(isOper(info)) + // setControl( irc, info[1], 'op', info[2]) + parseOp( irc, info[1], 'op', info[2]) + //else { // NOOP: PM from unknown + //} + } else { + var matches = /PRIVMSG (#[^ ]+) :/.exec(info[0]); + if(matches && matches[1]) { + info.push(matches[1]); + sendMessage(info); + } else { + log('WARN: no channel for message: ' + + JSON.stringify({info:info,matches:matches})); + } + } + }); + + irc.raw('NICK ' + config.irc.nick); + irc.raw('USER ' + config.irc.user + ' 8 * :' + config.irc.real); + + if (irc.pingTimerID !== null) { + clearInterval(irc.pingTimerID); + } + + irc.pingTimerID = setInterval(function () { + irc.raw('PING BONG'); + }, 60000); + }; + + if (config.irc.ssl === true) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // Self-signed certificates + irc.socket = tls.connect(config.irc.port, config.irc.host); + irc.socket.on('secureConnect', connectHandler); + } else { + irc.socket = new net.Socket(); + irc.socket.on('connect', connectHandler); + irc.socket.connect(config.irc.port, config.irc.host); + } + + irc.socket.setEncoding('utf-8'); + irc.socket.setNoDelay(); + + irc.handle = function (data) { + var info; + + for (var i = 0; i < irc.listeners.length; i++) { + info = irc.listeners[i][0].exec(data); + + if (info) { + irc.listeners[i][1](info, data); + + if (irc.listeners[i][2]) { + irc.listeners.splice(i, 1); + } + } + } + }; + + irc.on = function (data, callback) { + irc.listeners.push([data, callback, false]); + }; + + irc.on_once = function (data, callback) { + irc.listeners.push([data, callback, true]); + }; + + irc.raw = function(data) { + if (data !== '') { + irc.socket.write(data + '\n', 'utf-8'); + log('IRC -> ' + data); + } + }; + + irc.socket.on('data', function (data) { + data = data.split("\n"); + + for (var i = 0; i < data.length; i++) { + if (data[i] !== '') { + log('IRC <- ' + data[i]); + irc.handle(data[i].slice(0, -1)); + } + } + }); + + irc.socket.on('close', function () { + makeIRC(); + }); + + irc.socket.on('error', function () { + makeIRC(); + }); +} + +makeIRC(); +// */