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.
1215 lines
36 KiB
JavaScript
1215 lines
36 KiB
JavaScript
/* 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();
|
|
// */
|