telehash
Version:
A telehash library for node and browserify
379 lines (313 loc) • 11.4 kB
JavaScript
var crypto = require('crypto');
var streamlib = require('stream');
var log = require("../lib/util/log")("Chat")
// implements https://github.com/telehash/telehash.org/blob/master/v3/channels/chat.md
exports.name = 'chat';
exports.mesh = function(mesh, cbMesh)
{
var lib = mesh.lib;
var self = {open:{}, chats:{}};
// overwrite-able callback for invites
var cbInvite = false;
mesh.invited = function(handler){ cbInvite = handler; };
// create/join a new chat
mesh.chat = function(args, profile, cbReady)
{
// javascript is lame
if(typeof profile == 'function')
{
cbReady = profile;
profile = false;
}
if(!profile)
{
profile = args;
args = {};
}
function readyUp(err)
{
if(err) log.debug('chat error', err);
if(!cbReady) return;
// I wish node unrolled itself
var cb = cbReady;
cbReady = false;
cb(err, chat);
}
// accept uri arg
if(typeof args == 'string')
{
var leader = mesh.link(args);
if(!leader || !leader.args.query.id) return readyUp('bad uri: '+args);
args = {leader:leader,id:leader.args.query.id};
}
if(typeof args != 'object') return readyUp('bad args');
// generate or load basics for the unique chat id
var chat = {};
chat.secret = args.secret || crypto.randomBytes(8);
chat.depth = args.depth || 1000;
chat.seq = args.seq || chat.depth;
chat.id = args.id || stamp();
self.chats[chat.id] = chat;
chat.leader = args.leader || mesh;
chat.leading = (chat.leader == mesh) ? true : false;
chat.messages = {}; // index of all cached chat messages "id":{...}
chat.log = []; // ordered combined history ["id","id"]
chat.profiles = {}; // profiles by hashname
chat.last = {}; // last message id by hashname
chat.invited = {}; // ACL
chat.inbox = new streamlib.Readable({objectMode:true});
chat.inbox._read = function(){}; // all evented
chat.outbox = new streamlib.Writable({objectMode:true});
chat.streams = {}; // by hashname
// sanitize our profile
if(typeof profile == 'string') profile = {json:{text:profile}}; // convenient
if(!profile.json) profile = {json:profile};
profile.json.type = 'profile';
profile.json.id = (chat.leading) ? chat.id : stamp();
chat.profile = lib.lob.packet(profile.json, profile.body);
// internal fail handler
function fail(err, cbErr)
{
if(!err) return; // only catch errors
log.warn('chat fail',err);
chat.err = err;
// TODO error inbox/outbox
if(typeof cbErr == 'function') cbErr(err);
readyUp(err);
}
// internal message id generator
function stamp()
{
if(!chat.seq) return fail('chat history overflow, please restart');
var id = lib.hashname.siphash(mesh.hashname, chat.secret);
for(var i = 0; i < chat.seq; i++) id = lib.hashname.siphash(id.key,id);
chat.seq--;
return lib.base32.encode(id);
}
// internal to cache and event a message
chat.receive = function(from, msg)
{
msg.from = from;
// massage join's attached profile
if(msg.json.type == 'join')
{
msg.join = lib.lob.decode(msg.body);
msg.json.join = msg.join.json; // convenience
}
// put in our caches/log
if(msg.json.type == 'chat' || msg.json.type == 'join')
{
if(chat.messages[msg.json.id] && chat.messages[msg.json.id].json.text == msg.json.text) return log.debug('ignoring duplicate message');
chat.messages[msg.json.id] = msg;
chat.last[from] = msg.json.id;
// TODO put ordered in chat.log
chat.log.unshift(msg);
}
log.debug('receiving message',msg.from,msg.json);
chat.inbox.push(msg);
}
// internal to add a profile
chat.add = function(from, profile)
{
profile.from = from;
chat.profiles[from] = profile;
if(!chat.last[from]) chat.last[from] = profile.json.id;
chat.messages[profile.json.id] = profile;
log.debug('added profile',profile.json.id,from);
}
// this channel is ready
chat.connect = function(link, channel, last)
{
log.debug('chat connected',link.hashname);
// see if replacing existing stream
if(chat.streams[link.hashname])
{
// TODO BUGGY
// chat.streams[link.hashname].end();
}else{
chat.receive(link.hashname, lib.lob.packet({type:'connect'}));
}
var stream = chat.streams[link.hashname] = mesh.streamize(channel, 'lob');
stream.on('data', function(msg){
// make sure is sequential/valid id
if(!msg.json.id) return log.debug('bad message',msg.json);
var next = lib.base32.encode(lib.hashname.siphash(link.hashname, lib.base32.decode(msg.json.id)));
if(msg.json.id != chat.last[link.hashname] && next != chat.last[link.hashname]) return log.warn('unsequenced message',msg.json,chat.last[link.hashname]);
chat.receive(link.hashname, msg);
});
stream.on('end', function(){
log.debug('chat stream ended',link.hashname);
if(chat.streams[link.hashname] == stream)
{
chat.receive(link.hashname, lib.lob.packet({type:'disconnect'}));
delete chat.streams[link.hashname];
}
});
// send any messages since the last they saw
function sync(id)
{
if(id == last.json.id) return;
// bottoms up, send older first
sync(lib.base32.encode(lib.hashname.siphash(mesh.hashname, lib.base32.decode(id))));
stream.write(chat.messages[id]);
}
sync(chat.last[mesh.hashname]);
// signal good startup
readyUp();
return stream;
}
chat.join = function(link, profile)
{
chat.invited[link.hashname] = true;
// if we don't have their profile yet, send a join
if(!chat.profiles[link.hashname])
{
var open = {json:{type:'profile',chat:chat.id,seq:1}};
if(profile) open.json.profile = profile;
var channel = link.x.channel(open);
channel.send(open);
var stream = mesh.streamize(channel,'lob');
stream.write(chat.profile);
stream.on('error', function(err){
if(link == chat.leader) fail(err);
});
stream.on('finish',function(){
if(chat.profiles[link.hashname]) chat.join(link);
});
stream.end();
return;
}
// already connected
if(chat.streams[link.hashname]) return;
// let's get chatting
var open = {json:{type:'chat',chat:chat.id,seq:1}};
open.json.last = chat.last[link.hashname];
var chan = link.x.channel(open);
chan.receiving = function(err, packet, cbMore) {
if(packet)
{
var last = chat.messages[packet.json.last];
if(!last || last.from != mesh.hashname) return cbMore('unknown last '+packet.json.last);
chat.connect(link, chan, last);
cbMore();
}
}
chan.send(open);
}
// outgoing helper
chat.send = function(msg)
{
if(typeof msg == 'string') msg = {json:{text:msg}}; // convenient
if(!msg.json) msg = {json:msg}; // friendly to make a packet
if(!msg.json.type) msg.json.type = 'chat';
if(!msg.json.id) msg.json.id = stamp();
if(!msg.json.at) msg.json.at = Math.floor(Date.now()/1000);
if(!msg.json.after && chat.log[0]) msg.json.after = chat.log[0].json.id;
msg = lib.lob.packet(msg.json,msg.body); // consistency
// we receive it first
chat.receive(mesh.hashname, msg);
// deliver to anyone connected
Object.keys(chat.streams).forEach(function(to){
log.debug('sending to',to,msg.json.id);
chat.streams[to].write(msg);
});
}
// read messages from stream too
chat.outbox._write = function(data, enc, cbDone){
chat.send(data);
cbDone();
};
// always add ourselves
chat.add(mesh.hashname, chat.profile);
// if not the leader, send our profile to them to start
if(!chat.leading)
{
chat.join(chat.leader);
}else{
// leader auto-joins
chat.send({json:{type:'join',from:mesh.hashname},body:lib.lob.encode(chat.profile)});
readyUp();
}
return chat;
}
self.open.profile = function(args, open, cbOpen){
var link = this;
// ensure valid request
var id = lib.base32.decode(open.json.chat);
if(!id || id.length != 8) return cbOpen('invalid chat id');
// accept and stream until the profile
var chan = link.x.channel(open);
var stream = mesh.streamize(chan, 'lob');
if(!stream) return cbOpen('invalid open');
// wait for the profile message
stream.on('data', function(profile){
if(profile.json.type != 'profile' || !profile.json.id) return cbOpen('bad profile');
log.debug('join request',open.json,profile.json);
stream.end(); // send all done
// process invites for unknown chats
var chat = self.chats[open.json.chat];
if(!chat)
{
if(open.json.chat != profile.json.id) return cbOpen('unknown chat');
if(!cbInvite) return cbOpen('cannot accept invites');
// send the invite request to the app
cbInvite(link, profile);
return;
}
// if they're the leader, accept their profile
if(chat.leader == link)
{
// see if they need our profile yet
if(!open.json.profile) chat.join(link, profile.json.id);
// cache/track the profile
chat.add(link.hashname, profile);
// this will now connect
if(open.json.profile) chat.join(link);
return;
}
// not invited is request to join
if(!chat.invited[link.hashname])
{
// event the profile for the app to decide on
chat.receive(link.hashname, profile);
return;
}
// see if they need our profile yet
if(!open.json.profile) chat.join(link, profile.json.id);
// add new profile, send a join message
if(!chat.profiles[link.hashname])
{
chat.add(link.hashname, profile);
var join = {json:{type:'join',from:link.hashname},body:lib.lob.encode(profile)};
chat.send(join);
}
// if they have ours, this will get connected now
if(open.json.profile) chat.join(link);
});
// process stream start
chan.receive(open);
}
self.open.chat = function(args, open, cbOpen){
var link = this;
log.debug('chat request',open.json);
// ensure valid request
var id = lib.base32.decode(open.json.chat);
if(!id || id.length != 8) return cbOpen('invalid chat id');
var chat = self.chats[open.json.chat];
if(!chat) return cbOpen('unknown chat');
if(!chat.profiles[link.hashname]) return cbOpen('no profile');
var last = chat.messages[open.json.last];
if(!last || last.from != mesh.hashname) return cbOpen('unknown last '+open.json.last);
var chan = link.x.channel(open);
chan.receive(open);
// confirm
chan.send({json:{last:chat.last[link.hashname]}});
chat.connect(link, chan, last);
}
cbMesh(undefined, self);
}
exports.Log = function(args){
Object.keys(args).forEach(function(type){
log[type] = args[type].bind(console)
})
}