From fbd2648fa6637f4bc980f1ec77edaa9e7b20bf3f Mon Sep 17 00:00:00 2001 From: yellows111 Date: Mon, 26 Feb 2024 14:33:55 +0000 Subject: [PATCH] initial commit should practically work as long as you know how to copy a url --- .gitignore | 2 + README.md | 16 ++++ client.js | 197 ++++++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 22 ++++++ package.json | 19 +++++ server.js | 183 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 439 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 client.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd9865a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +tmp/* +node_modules/* \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef6c883 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Messenger.js Third Edition +A MSN(r) Messenger(tm) Activity API Emulation script client with a WebSocket server. + +## Why? +I wanted to play the old MSN(r) Messenger(tm) Activities online. + +## Client compatibility: +Server: whatever node.js version uWS supports +Client: does it ES6? + +## TODOs +* Client synchronization should be "less bad and less prone to desynching" + +## Desires +* Discover more Activities that aren't Flash-based, and hope they don't use some other foregone extension. +* Pray Ruffle finally starts supporting the scripability APIs needed to support Flash-based activities. \ No newline at end of file diff --git a/client.js b/client.js new file mode 100644 index 0000000..c7995f4 --- /dev/null +++ b/client.js @@ -0,0 +1,197 @@ +const channelSocket = new WebSocket("ws://localhost:9091/connect"); +let sessionID = null; +let imTheRoomOwner = false; +let myNickname = ""; +/** @section guacamole **/ +const ParseCommandString = function(instruction) { + if(typeof instruction !== "string") { + return; + } + let position = -1; + let sections = new Array(); + + for(;;) { + let length = instruction.indexOf('.', position + 1); + + if(length === -1) { + break; + } + + position = (parseInt(instruction.slice(position + 1, length)) + length) + 1; // todo: not overflow + sections.push(instruction.slice(length + 1, position) + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(///g, '/') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + ); + + if(instruction.slice(position, position + 1) === ';') { + break; + } + } + return sections; +} +const EncodeCommandString = function(sections) { + if(Array.isArray(sections) === false) { + return; + } + let instruction = new String(); + const argv = sections; + for (let argc = 0; argc < sections.length ; argc++) { + if(typeof sections[argc] !== "string") { + argv[argc] = sections[argc].toString(); + } + argv[argc] = argv[argc] + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/\//g, '/' ) + .replace(//g, '>' ) + .replace(/&/g, '&' ) + ; + instruction = instruction.concat(argv[argc].length.toString(), ".", argv[argc]) + instruction += (argc === sections.length - 1) ? ';' : ','; + } + return instruction; +} + +/** window.external **/ +window.external.CloseApp = function() { + channelSocket.close(1000, "called CloseApp"); +} +// this is important because things expect it +/** window.external.Channel **/ +window.external.Channel = {"Data": "", "SendData": null, "Initialize": null}; // "Type" is a defined property +window.external.Channel.SendData = function(d) {channelSocket.send(d)}; // this is dumb but trying to redirect this gives you TypeError's +window.external.Channel.Initialize = function() { + if(new URL(location.href).searchParams.get("channel") === null) { + sessionID = Math.random().toString(36).split('.')[1].substring(0,8); + history.pushState(null, "", `?channel=${sessionID}`); + } else { + sessionID = new URL(location.href).searchParams.get("channel"); + } + createWSEventsNowThatImReady(); +}; +Object.defineProperty(window.external.Channel, "Type", { + get: function Type() { + if(channelSocket.readyState === 1) { + return 1; // Indirect; + } else { + return 2; // Disconnected; + } + } +}); +/** window.external.Users **/ +// This sadly needs to be already configured before an activity tries using it... +// We can't just block execution until we've got the session... +window.external.yellows111_Users = [ + {"Name": "Initiator", "Email": "you@messenger.js", "GlobalIP": "0.0.0.0", "LocalIP": "0.0.0.0", "PUID": "0"}, + {"Name": "Target", "Email": "opponent@messenger.js", "GlobalIP": "0.0.0.0", "LocalIP": "0.0.0.1", "PUID": "1"} +]; +window.external.Users = {"Me": null, "me": null, "Inviter": null, "Item": null}; // "Count" is a defined property +window.external.Users.Item = function(which){ + return window.external.yellows111_Users[which]; +} +Object.defineProperty(window.external.Users, "Count", { + get: function Count() { + return window.external.yellows111_Users.length; + } +}); +if(new URL(location.href).searchParams.get("channel") === null) { + window.external.Users.Me = window.external.yellows111_Users[0]; + window.external.Users.me = window.external.yellows111_Users[0]; + window.external.Users.Inviter = window.external.yellows111_Users[0]; + document.title = "Initiator: " + document.title; +} else { + window.external.Users.Me = window.external.yellows111_Users[1]; + window.external.Users.me = window.external.yellows111_Users[1]; + window.external.Users.Inviter = window.external.yellows111_Users[0]; + document.title = "Target: " + document.title; +} + +/** websocket stuff **/ +channelSocket.binaryType = "arraybuffer"; // what's the point of doing Blob.arrayBuffer every binary call... which is awful because IT'S A PROMISE CALL AND PROMISES SUXX +channelSocket.onmessage = function(event) { + switch(event.data.constructor.name) { + case "String": { + // is a Activity message + window.external.Channel.Data = event.data; + Channel_OnDataReceived(); + break; + } + case "ArrayBuffer": { + // is a Command message + let args = ParseCommandString(new TextDecoder("utf8").decode(event.data)); + switch(args[0]) { + case "rename": { + myNickname = args[3]; + break; + } + case "connect": { + switch(args[1]) { + case 0: { + console.error("room is full or not avaliable"); + break; + } + case 1: { + console.log("joined room successfully."); + if(parseInt(args[2])) { + imTheRoomOwner = false; + } else { + imTheRoomOwner = true; + //window.external.Users.Inviter = window.external.Users.Me; + } + break; + } + case 2: { + console.log("disconnecting?"); + break; + } + } + break; + } + case "adduser": { + if(imTheRoomOwner === false) { + window.external.yellows111_Users[1].Name = myNickname; + } + window.external.yellows111_Users[0].Name = args[2]; + Channel_OnRemoteAppLoaded(); + Channel_OnTypeChanged(); + break; + } + case "deluser": { + // todo? + Channel_OnTypeChanged(); + break; + } + case "sync": { + channelSocket.send(new TextEncoder("utf8").encode(EncodeCommandString(["sync", (new Date).getTime()]))); + break; + } + default: { + console.warn("unknown instruction sequence:", args); + } + } + break; + } + } +} +channelSocket.onerror = function() { + Channel_OnDataError(); +} +channelSocket.onclose = function() { + Channel_OnAppClose(); +} +function createWSEventsNowThatImReady() { + if(channelSocket.readyState === 1) { + // if we're already there + channelSocket.send(new TextEncoder("utf8").encode(EncodeCommandString(["rename"]))); + channelSocket.send(new TextEncoder("utf8").encode(EncodeCommandString(["connect", sessionID]))); + } + channelSocket.onopen = function() { + channelSocket.send(new TextEncoder("utf8").encode(EncodeCommandString(["rename"]))); + channelSocket.send(new TextEncoder("utf8").encode(EncodeCommandString(["connect", sessionID]))); + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6f7417b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,22 @@ +{ + "name": "messenger-js", + "version": "3.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "messenger-js", + "version": "3.0.0", + "license": "UNLICENSED", + "dependencies": { + "uWebSockets.js": "github:uNetworking/uWebSockets.js#semver:*" + }, + "devDependencies": {} + }, + "node_modules/uWebSockets.js": { + "version": "20.42.0", + "resolved": "git+ssh://git@github.com/uNetworking/uWebSockets.js.git#f40213ec0a97d0d8721d9d32d92d6eb6ddcd22e7", + "license": "Apache-2.0" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7816e33 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "dependencies": { + "uWebSockets.js": "github:uNetworking/uWebSockets.js#semver:*" + }, + "name": "messenger-js", + "version": "3.0.0", + "description": "An emulation library for the MSN Messenger(tm) Activity API, with a server.", + "main": "server.js", + "devDependencies": {}, + "repository": { + "type": "git", + "url": "none" + }, + "keywords": [ + "websocket" + ], + "author": "yellows111", + "license": "UNLICENSED" +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..d76e808 --- /dev/null +++ b/server.js @@ -0,0 +1,183 @@ +const config = {port: 9091}; +var uidcounter = 0; +Object.freeze(config); // Config should not be modified after initialization! + +const ChannelStorage = new Map(); +const UserAttachedChannelStorage = new Map(); +const UserAttachedNameStorage = new Map(); // uid=>username resolve +const ReverseNameToUidStorage = new Map(); + +/** @section guacamole **/ +const ParseCommandString = function(instruction) { + if(typeof instruction !== "string") { + return; + } + let position = -1; + let sections = new Array(); + + for(;;) { + let length = instruction.indexOf('.', position + 1); + + if(length === -1) { + break; + } + + position = (parseInt(instruction.slice(position + 1, length)) + length) + 1; // todo: not overflow + sections.push(instruction.slice(length + 1, position) + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(///g, '/') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + ); + + if(instruction.slice(position, position + 1) === ';') { + break; + } + } + return sections; +} +const EncodeCommandString = function(sections) { + if(Array.isArray(sections) === false) { + return; + } + let instruction = new String(); + const argv = sections; + for (let argc = 0; argc < sections.length ; argc++) { + if(typeof sections[argc] !== "string") { + argv[argc] = sections[argc].toString(); + } + argv[argc] = argv[argc] + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/\//g, '/' ) + .replace(//g, '>' ) + .replace(/&/g, '&' ) + ; + instruction = instruction.concat(argv[argc].length.toString(), ".", argv[argc]) + instruction += (argc === sections.length - 1) ? ';' : ','; + } + return instruction; +} +/** @section sv_functions **/ +const MessageParser = function(webSocket, message, isBinary) { + const msg = new TextDecoder("utf-8").decode(message); + if(isBinary) { + // TODO: command functions + try { + const args = ParseCommandString(msg); + switch(args[0]) { + case "rename": { + // todo: make users be able to rename based on parameters given to rename + var nickname = `guest${Math.floor(Math.random() * 99999)}`; + // this should probably be a while or something + if(ReverseNameToUidStorage.get(nickname) !== webSocket.getUserData().uid) { + nickname = `guest${Math.floor(Math.random() * 99999)}`; + } + UserAttachedNameStorage.set(webSocket.getUserData().uid, nickname); + ReverseNameToUidStorage.set(nickname, webSocket.getUserData().uid); + webSocket.send(EncodeCommandString(["rename", 0, "", nickname]), true); + break; + } + case "connect": { + if(args.length <= 1) { + webSocket.send(EncodeCommandString(["connect", 0]), true); + break; + } + let getChannelId = args[1]; + if(server.numSubscribers(`channels/${getChannelId}`) >= 2) { + // this room is full + webSocket.send(EncodeCommandString(["connect", 0]), true); + break; + } + if(typeof UserAttachedNameStorage.get(webSocket.getUserData().uid) === "undefined") { + // user doesn't have a username + webSocket.send(EncodeCommandString(["connect", 0]), true); + break; + } + if(typeof ChannelStorage.get(getChannelId) === "undefined") { + // make the channel right then and there + ChannelStorage.set(getChannelId, {"owner": webSocket.getUserData().uid}); + }; + if(webSocket.isSubscribed(`channels/${getChannelId}`) === true) { + // already in this channel + webSocket.send(EncodeCommandString(["connect", 0]), true); + break; + }; + if(typeof UserAttachedChannelStorage.get(webSocket.getUserData().uid) !== "undefined") { + // already in a channel that isn't the one we're trying to join + webSocket.send(EncodeCommandString(["connect", 0]), true); + break; + } + webSocket.send(EncodeCommandString(["connect", 1, server.numSubscribers(`channels/${getChannelId}`)]), true); + webSocket.subscribe(`channels/${getChannelId}`); + UserAttachedChannelStorage.set(webSocket.getUserData().uid, getChannelId); + // do we keep adduser? + if(ChannelStorage.get(getChannelId).owner !== webSocket.getUserData().uid) { + // send other user's presence if we don't own the channel (if we're not owner) + webSocket.send(EncodeCommandString(["adduser", 1, UserAttachedNameStorage.get(ChannelStorage.get(getChannelId).owner)]), true); + } + // send to other user (or nobody) that we joined this channel + webSocket.publish(`channels/${getChannelId}`, EncodeCommandString(["adduser", 1, UserAttachedNameStorage.get(webSocket.getUserData().uid)]), true); + break; + } + case "sync": { + // client is alive... + break; + } + default: { + console.warn("unknown command", args); + } + } + } catch(e) { + console.log(e); + webSocket.end(1003, new TextEncoder("utf-8").encode("Unknown format.")); + return; + } + return; + } else { + // send data to channel + webSocket.publish(`channels/${UserAttachedChannelStorage.get(webSocket.getUserData().uid)}`, message, false); + // ask other user if alive still + webSocket.publish(`channels/${UserAttachedChannelStorage.get(webSocket.getUserData().uid)}`, EncodeCommandString(["sync", (new Date).getTime()]), true); + } + //console.log(`${new TextDecoder("utf-8").decode(webSocket.getRemoteAddressAsText())} message: ${msg}`); +} +const UserStateRequest = function(webSocket, state, data = null) { + switch(state) { + case "open": { + //console.log("%s client hello", new TextDecoder("utf-8").decode(webSocket.getRemoteAddressAsText())); + break; + } + case "close": { + // close event gets WS disconnect code and the client's termination message (avaliable if data.code is 1000, 3xxx, or 4xxx, but only 1000 is standard) + // but we DO NOT get to access any WS methods, since they've been closed, so we don't actually know who left. + if(data.code === 1000) { + console.log(`client exiting, reason: ${new TextDecoder("utf-8").decode(data.message)}`); + } + break; + } + } +} +/** @section creation **/ +const server = require("uWebSockets.js").App({}); +/** @section config **/ +server.ws("/connect", { + // options + "compression": require("uWebSockets.js").DEDICATED_COMPRESSOR_3KB, // do we need a compressor for this kind of protocol, uWS actively despises compression, so it's worth considering. + // here be events + "upgrade": function(rs, rq, cx) { + rs.upgrade({"uid": (uidcounter++)}, /* very important, this sets a UserData object (which can contain anything we like) to the WSConnection instance. This can only be done here. */ + rq.getHeader("sec-websocket-key"), + rq.getHeader("sec-websocket-protocol"), + rq.getHeader("sec-websocket-extensions"), + cx); + }, + "message": MessageParser, + "open": function(ws) {UserStateRequest(ws, "open")}, + "close": function(ws, code, message) {UserStateRequest(ws, "close", {code, message})}, + "drain": function(ws) {console.log(`WS going through back-pressure!: ${ws.getBufferedAmount()}`)} +}); +server.listen(config.port, function(token) {console.log(token ? `open on ${config.port}` : `failed to listen to ${config.port}`)}); \ No newline at end of file