mami/src/proto.js/sockchat/proto.js
flash cf71bab92d Rewrote connection handling.
This has been in the works for over a month and might break things because it's a very radical change.
If it causes you to be unable to join chat, report it on the forum or try joining using the legacy chat on https://sockchat.flashii.net.
2024-04-17 15:42:50 +00:00

249 lines
7.6 KiB
JavaScript

#include timedp.js
#include sockchat/authed.js
#include sockchat/ctx.js
#include sockchat/unauthed.js
const SockChatProtocol = function(dispatch, options) {
if(typeof dispatch !== 'function')
throw 'dispatch must be a function';
if(typeof options !== 'object' || options === null)
throw 'options must be an object';
if(typeof options.ping !== 'number')
throw 'options.ping must be a number';
let ctx, sock;
let dumpPackets = false;
const handlers = {
unauthed: {
'1': {
'y': SockChatS2CAuthSuccess,
'n': SockChatS2CAuthFail,
},
'7': {
// MOTD gets sent before auth success :D
'1': SockChatS2CMessagePopulate,
},
},
authed: {
'0': SockChatS2CPong,
'1': SockChatS2CUserAdd,
'2': SockChatS2CMessageAdd,
'3': SockChatS2CUserRemove,
'4': {
'0': SockChatS2CChannelAdd,
'1': SockChatS2CChannelUpdate,
'2': SockChatS2CChannelRemove,
},
'5': {
'0': SockChatS2CUserChannelJoin,
'1': SockChatS2CUserChannelLeave,
'2': SockChatS2CUserChannelFocus,
},
'6': SockChatS2CMessageRemove,
'7': {
'0': SockChatS2CUserPopulate,
'1': SockChatS2CMessagePopulate,
'2': SockChatS2CChannelPopulate,
},
'8': SockChatS2CContextClear,
'9': SockChatS2CBanKick,
'10': SockChatS2CUserUpdate,
},
};
const handleOpen = () => {
if(dumpPackets)
console.log('[sockchat:open]');
ctx.isRestarting = false;
ctx.keepAlive.start();
const detail = {
opitons: options,
wasConnected: ctx.wasConnected,
wasKicked: ctx.wasKicked,
};
dispatch('conn:open', detail);
// event (and maybe also resolve?) should be delayed until after capability negotiation
dispatch('conn:ready');
ctx.openPromise?.resolve(detail);
};
const handleClose = ev => {
if(dumpPackets)
console.log('[sockchat:close]', ev.code, ev.reason, ev.wasClean);
ctx.keepAlive.stop();
ctx.userId = undefined;
ctx.channelName = undefined;
ctx.pseudoChannelName = undefined;
if(ev.code === 1012)
ctx.isRestarting = true;
const detail = {
code: ev.code,
wasConnected: ctx.wasConnected,
isRestarting: ctx.isRestarting,
wasKicked: ctx.wasKicked,
};
dispatch('conn:lost', detail);
ctx.openPromise?.reject(detail);
};
const handleMessage = ev => {
const args = ev.data.split("\t");
if(dumpPackets)
console.log('[sockchat:incoming]', args);
let handler = handlers[ctx.isAuthed ? 'authed' : 'unauthed'];
for(;;) {
handler = handler[args.shift()];
if(handler === undefined)
break;
if(typeof handler === 'function') {
handler(ctx, ...args);
break;
}
}
};
const send = (...args) => {
if(args.length < 1)
throw 'you must specify at least one argument as an opcode';
if(ctx?.dumpPackets)
console.log('[sockchat:outgoing]', args);
sock?.send(args.join("\t"));
};
const sendPing = () => {
return new Promise((resolve, reject) => {
if(ctx === undefined)
throw 'no connection opened';
if(!ctx.isAuthed)
throw 'must be authenticated';
if(ctx.pingPromise !== undefined)
throw 'already sending a ping';
ctx.lastPing = Date.now();
ctx.pingPromise = new TimedPromise(resolve, reject, () => ctx.pingPromise = undefined, 2000);
send('0', ctx.userId);
});
};
const sendAuth = (...args) => {
return new Promise((resolve, reject) => {
if(ctx === undefined)
throw 'no connection opened';
if(ctx.isAuthed)
throw 'already authenticated';
if(ctx.authPromise !== undefined)
throw 'already authenticating';
// HttpClient in C# has its Moments, so lets give this way too long to do its thing
ctx.authPromise = new TimedPromise(resolve, reject, () => ctx.authPromise = undefined, 10000);
send('1', ...args);
});
};
const sendMessage = text => {
return new Promise((resolve, reject) => {
if(typeof text !== 'string')
throw 'text must be a string';
if(ctx === undefined)
throw 'no connection opened';
if(!ctx.isAuthed)
throw 'must be authenticated';
// there's actually a pretty big bug here lol
// any unsupported command is gonna fall through to the actual channel you're in
if(!text.startsWith('/') && ctx.pseudoChannelName !== undefined)
text = `/msg ${ctx.pseudoChannelName} ${text}`;
send('2', ctx.userId, text);
// server doesn't send a direct ACK and we can't tell what message
// which response messages is actually associated with this
// an ACK or request/response extension to the protocol will be required
if(typeof resolve === 'function')
resolve();
});
};
const createContext = () => {
ctx = new SockChatContext(dispatch, sendPing, options.ping);
};
const closeWebSocket = () => {
try {
const localSock = sock;
sock = undefined;
if(localSock !== undefined) {
localSock.removeEventListener('message', handleMessage);
localSock.removeEventListener('close', handleClose);
localSock.removeEventListener('open', handleOpen);
localSock.close();
}
} finally {
ctx?.dispose();
}
};
return {
setDumpPackets: state => {
dumpPackets = !!state;
},
open: url => {
return new Promise((resolve, reject) => {
if(typeof url !== 'string')
throw 'url must be a string';
if(ctx?.openPromise !== undefined)
throw 'already opening a connection';
closeWebSocket();
createContext();
ctx.openPromise = new TimedPromise(resolve, reject, () => ctx.openPromise = undefined, 5000);
sock = new WebSocket(url);
sock.addEventListener('open', handleOpen);
sock.addEventListener('close', handleClose);
sock.addEventListener('message', handleMessage);
});
},
close: () => { closeWebSocket(); },
sendPing: sendPing,
sendAuth: sendAuth,
sendMessage: sendMessage,
switchChannel: async info => {
if(!ctx.isAuthed)
return;
const name = info.name;
if(info.isUserChannel) {
selfPseudoChannelName = name.substring(1);
} else {
ctx.pseudoChannelName = undefined;
if(ctx.channelName === name)
return;
ctx.channelName = name;
await sendMessage(`/join ${name}`);
}
},
};
};