From 09a283fdbcea5987f0f66d67d52710151858f271 Mon Sep 17 00:00:00 2001 From: Cory McWilliams Date: Sun, 15 May 2016 13:23:38 +0000 Subject: [PATCH] A detachable multi-protocol chat client is starting to come together. git-svn-id: https://www.unprompted.com/svn/projects/tildefriends/trunk@3232 ed5197a5-7fde-0310-b194-c3ffbd925b24 --- packages/cory/chat/chat.js | 131 +++++++++++++++++----------- packages/cory/irc/irc.js | 140 ++++++++++++++++++++++++++++++ packages/cory/libchat/libchat.js | 126 +++++++++++++++++++++++++++ packages/cory/xmpp2/xmpp2.js | 141 +++++++------------------------ 4 files changed, 377 insertions(+), 161 deletions(-) create mode 100644 packages/cory/irc/irc.js create mode 100644 packages/cory/libchat/libchat.js diff --git a/packages/cory/chat/chat.js b/packages/cory/chat/chat.js index d6b9380e..94fbe138 100644 --- a/packages/cory/chat/chat.js +++ b/packages/cory/chat/chat.js @@ -4,6 +4,7 @@ var gFocus = true; var gUnread = 0; var gPresence = {}; let gSessions = {}; +let gState = {}; let gCurrentConversation; function updateTitle() { @@ -11,6 +12,7 @@ function updateTitle() { } let kAccountsKey = JSON.stringify(["accounts", core.user.name]); +let kStateKey = JSON.stringify(["state", core.user.name]); function runCommand(data) { if (data.action == "addAccount") { @@ -27,12 +29,18 @@ function runCommand(data) { } else if (data.action == "disconnect") { disconnect(data.id); } else if (data.action == "window") { - gCurrentConversation = gSessions[data.account].conversations[data.conversation]; - updateConversation(); - updateWindows(); + setWindow(data.account, data.conversation); } } +function setWindow(accountId, conversation) { + gState.window = {account: accountId, conversation: conversation}; + database.set(kStateKey, JSON.stringify(gState)); + gCurrentConversation = gSessions[accountId].conversations[conversation]; + updateConversation(); + updateWindows(); +} + function addAccount() { return database.get(kAccountsKey).then(function(data) { let accounts = data ? JSON.parse(data) : []; @@ -152,10 +160,11 @@ function connect(id) { self.session = session; gSessions[id] = session; session.conversations = {}; - getConversation(session, {}); + session.account = account; + getConversation(session, null); session.getConversations().then(function(conversations) { for (let j in conversations) { - getConversation(session, {conversation: conversations[j]}); + getConversation(session, conversations[j]); } }); }); @@ -208,13 +217,18 @@ function updateConversation() { ]).then(function(data) { let history = data[0]; let participants = data[1]; - gCurrentConversation.messages = history; - gCurrentConversation.participants = participants; + gCurrentConversation.messages = history || []; + gCurrentConversation.participants = participants || []; terminal.cork(); terminal.select("terminal"); terminal.clear(); for (var i in gCurrentConversation.messages) { - printMessage(gCurrentConversation.messages[i]); + let message = gCurrentConversation.messages[i]; + if (message.action == "message") { + printMessage(message.message); + } else { + terminal.print(message); + } } updateUsers(); terminal.uncork(); @@ -255,65 +269,81 @@ terminal.select("terminal"); terminal.print("~Friends Chat"); terminal.uncork(); -function getConversation(session, message) { +function getConversation(session, conversationName) { let result; - for (var i in gSessions) { - if (session == gSessions[i]) { - let key = message.conversation || message.from || ""; - if (!session.conversations[key]) { - session.conversations[key] = { - session: session, - name: key, - messages: [], - sendMessage: function(message) { - return session.sendMessage(key, message); - }, - }; - updateWindows(); + let key = conversationName || ""; + if (!session.conversations) { + session.conversations = {}; + } + if (!session.conversations[key]) { + session.conversations[key] = { + session: session, + name: key, + messages: [], + sendMessage: function(message) { + return session.sendMessage(key, message); + }, + }; + updateWindows(); + } + result = session.conversations[key]; + if (result) { + if (!gCurrentConversation) { + if (!gState.window) { + setWindow(session.account.id, key); + } else if (gState.window.account = session.account.id && gState.window.conversation == key) { + setWindow(session.account.id, key); } - result = session.conversations[key]; - break; } } - if (result && !gCurrentConversation) { - gCurrentConversation = result; - } return result; } +function printToConversation(conversation, message, notify) { + if (conversation == gCurrentConversation) { + if (message.action == "message") { + printMessage(message.message); + } else { + terminal.print(message); + } + } + if (conversation) { + conversation.messages.push(message); + } + if (notify && !gFocus) { + gUnread++; + updateTitle(); + } +} + function chatCallback(event) { try { if (event.action == "message") { - let conversation = getConversation(this.session, event); - if (conversation == gCurrentConversation) { - printMessage(event); - } - conversation.messages.push(event); - - if (!gFocus) { - gUnread++; - updateTitle(); - } + let conversation = getConversation(this.session, event.conversation); + printToConversation(conversation, event); } else if (event.action == "presence") { - let conversation = event.jid.split('/', 2)[0]; - if (gCurrentConversation.name == conversation) { - let index = gCurrentConversation.participants.indexOf(event.name); - if (event.type == "unavailable") { - if (index != -1) { - gCurrentConversation.participants.splice(index, 1); + let conversation = getConversation(this.session, event.conversation); + let index = conversation.participants.indexOf(event.user); + if (event.presence == "unavailable") { + if (index != -1) { + conversation.participants.splice(index, 1); + if (conversation == gCurrentConversation) { updateUsers(); - terminal.print(new Date().toString(), ": ", event.name + " has left the room."); } - } else { - if (index == -1) { - gCurrentConversation.participants.push(event.name); + printToConversation(conversation, [new Date().toString(), ": ", event.user + " has left the room."]); + } + } else { + if (index == -1) { + conversation.participants.push(event.user); + if (conversation == gCurrentConversation) { updateUsers(); - terminal.print(new Date().toString(), ": ", event.name + " has joined the room."); } + printToConversation(conversation, [new Date().toString(), ": ", event.user + " has joined the room."]); } } } else { - terminal.print("Unhandled event: ", JSON.stringify(event)); + let conversation = getConversation(this.session, event.conversation); + printToConversation(conversation, ["Unhandled event: ", JSON.stringify(event)]); } } catch (error) { terminal.print("chatCallback: ", error); @@ -391,8 +421,9 @@ core.register("blur", function() { }); // Connect all accounts on start. -Promise.all([database.get(kAccountsKey), core.getPackages()]).then(function(results) { +Promise.all([database.get(kAccountsKey), database.get(kStateKey)]).then(function(results) { let accounts = results[0] ? JSON.parse(results[0]) : []; + gState = results[1] ? JSON.parse(results[1]) : gState; for (let i in accounts) { connect(accounts[i].id); } diff --git a/packages/cory/irc/irc.js b/packages/cory/irc/irc.js new file mode 100644 index 00000000..cad0cdad --- /dev/null +++ b/packages/cory/irc/irc.js @@ -0,0 +1,140 @@ +"use strict"; + +//! { +//! "permissions": [ +//! "network" +//! ], +//! "chat": { +//! "version": 1, +//! "settings": [ +//! {"name": "user", "type": "text"}, +//! {"name": "realName", "type": "text"}, +//! {"name": "password", "type": "password"}, +//! {"name": "nick", "type": "text"}, +//! {"name": "server", "type": "text"}, +//! {"name": "port", "type": "text"}, +//! {"name": "autoJoinChannels", "type": "text"} +//! ] +//! }, +//! "require": [ +//! "libchat" +//! ] +//! } + +let ChatService = require("libchat").ChatService; + +class IrcService { + constructor(options) { + let self = this; + self._service = new ChatService(options.callback); + self._name = options.name; + self._nick = options.nick; + + network.newConnection().then(function(socket) { + self._socket = socket; + return self._connect(options); + }); + } + + _send(line) { + return this._socket.write(line + "\r\n"); + } + + _receivedLine(originalLine) { + try { + let line = originalLine; + let prefix; + if (line.charAt(0) == ":") { + let space = line.indexOf(" "); + prefix = line.substring(1, space); + line = line.substring(space + 1); + } + let lineNoPrefix = line; + let remainder; + let colon = line.indexOf(" :"); + if (colon != -1) { + remainder = line.substring(colon + 2); + line = line.substring(0, colon); + } + let parts = line.split(" "); + if (remainder) { + parts.push(remainder); + } + + let conversation = ""; + if (parts[0] == "PRIVMSG" || parts[0] == "NOTICE") { + // Is it a channel type? + if ("&#!+.".indexOf(parts[1].charAt(0)) != -1) { + conversation = parts[1]; + } else { + conversation = prefix.split('!')[0]; + } + this._service.notifyMessageReceived(conversation, { + from: prefix.split('!')[0], + message: parts[parts.length - 1], + type: parts[0], + }); + } else if (parts[0] == "PING") { + parts[0] = "PONG"; + this._send(parts.join(" ")); + } else if (parts[0] == "JOIN") { + let person = prefix.split('!')[0]; + let conversation = parts[1]; + this._service.notifyPresenceChanged(conversation, person, "present"); + } else if (parts[0] == "JOIN") { + let person = prefix.split('!')[0]; + let conversation = parts[1]; + this._service.notifyPresenceChanged(conversation, person, "unavailable"); + } else { + this._service.notifyMessageReceived("", {from: prefix, message: lineNoPrefix}); + } + } catch (error) { + this._service.reportError(error); + } + } + + _connect(options) { + let self = this; + + let readBuffer = ""; + self._socket.read(function(data) { + if (data) { + readBuffer += data; + let end = readBuffer.indexOf("\n"); + while (end != -1) { + let line = readBuffer.substring(0, end); + if (line.charAt(line.length - 1) == "\r") { + line = line.substring(0, line.length - 1); + } + readBuffer = readBuffer.substring(end + 1); + self._receivedLine(line); + end = readBuffer.indexOf("\n"); + } + } else { + self._service.notifyStateChanged("disconnected"); + } + }); + return self._socket.connect(options.server, options.port).then(function() { + self._service.notifyStateChanged("connected"); + self._send("USER " + options.user + " 0 * :" + options.realName); + self._send("NICK " + options.nick); + }).catch(self._service.reportError); + } + + sendMessage(target, text) { + if (!target) { + this._socket.write(text + "\r\n"); + } else { + this._socket.write("PRIVMSG " + target + " :" + text + "\r\n"); + } + this._service.notifyMessageReceived(target || "", {from: self._nick, message: text, timestamp: new Date().toString()}); + } + + disconnect() { + this._send("QUIT"); + this._socket.close(); + this._service.notifyStateChanged("disconnected"); + } +}; + +ChatService.handleMessages(IrcService); \ No newline at end of file diff --git a/packages/cory/libchat/libchat.js b/packages/cory/libchat/libchat.js new file mode 100644 index 00000000..508a0f31 --- /dev/null +++ b/packages/cory/libchat/libchat.js @@ -0,0 +1,126 @@ +"use strict"; + +exports.ChatService = class { + static handleMessages(serviceClass) { + let self = this; + let sessions = {}; + + core.register("onMessage", function(sender, options) { + let service = sessions[options.name]; + if (!service) { + service = new serviceClass(options); + sessions[options.name] = service; + } else { + service._service.addCallback(options.callback); + } + return service._service.makeInterface(service); + }); + } + + constructor(callback) { + this._callbacks = [callback]; + this._conversations = {}; + this._state = null; + } + + makeInterface(service) { + let self = this; + return { + sendMessage: service.sendMessage.bind(service), + disconnect: service.disconnect.bind(service), + + getConversations: self.getConversations.bind(self), + getHistory: self.getHistory.bind(self), + getParticipants: self.getParticipants.bind(self), + }; + } + + addCallback(callback) { + if (this._callbacks.indexOf(callback) == -1) { + this._callbacks.push(callback); + } + } + + _invokeCallback(message) { + let self = this; + for (let i = self._callbacks.length - 1; i >= 0; i--) { + let callback = self._callbacks[i]; + try { + callback(message); + } catch (error) { + self._callbacks.splice(i, 1); + + // XXX: Send it to the other connections? + print(error); + } + } + } + + _getConversation(conversation) { + if (!this._conversations[conversation]) { + this._conversations[conversation] = {history: [], participants: []}; + } + return this._conversations[conversation]; + } + + notifyMessageReceived(conversation, message) { + let fullMessage = {action: "message", conversation: conversation || "", message: message}; + this._getConversation(conversation || "").history.push(fullMessage); + this._invokeCallback(fullMessage); + } + + notifyPresenceChanged(conversation, user, state) { + let leaving = state == "unavailable"; + let participants = this._getConversation(conversation).participants; + let index = participants.indexOf(user); + if (leaving) { + participants.splice(index, 1); + } else if (index == -1) { + participants.push(user); + } + this._invokeCallback({ + action: "presence", + conversation: conversation, + user: user, + presence: state, + }); + } + + notifyStateChanged(state) { + this._state = state; + this._invokeCallback({action: state}); + } + + reportError(error) { + this._invokeCallback({ + action: "error", + error: error, + }).catch(function(error) { + print(error); + }); + } + + isConversation(conversation) { + return this._conversations[conversation] != null; + } + + getConversations() { + return Object.keys(this._conversations); + } + + getHistory(conversation) { + let result; + if (this._conversations[conversation]) { + result = this._conversations[conversation].history; + } + return result; + } + + getParticipants(conversation) { + let result; + if (this._conversations[conversation]) { + result = this._conversations[conversation].participants; + } + return result; + } +} \ No newline at end of file diff --git a/packages/cory/xmpp2/xmpp2.js b/packages/cory/xmpp2/xmpp2.js index 6cea548d..bd946216 100644 --- a/packages/cory/xmpp2/xmpp2.js +++ b/packages/cory/xmpp2/xmpp2.js @@ -12,7 +12,10 @@ //! {"name": "resource", "type": "text", "default": "tildefriends"}, //! {"name": "server", "type": "text"} //! ] -//! } +//! }, +//! "require": [ +//! "libchat" +//! ] //! } // md5.js @@ -678,57 +681,23 @@ XmlStanzaParser.prototype.parseNode = function(node) { // end xmpp.js +let ChatService = require("libchat").ChatService; + var gPingCount = 0; class XmppService { constructor(options) { let self = this; - self._callbacks = [options.callback]; - self._conversations = {}; + self._service = new ChatService(options.callback); network.newConnection().then(function(socket) { self._socket = socket; return self._connect(options); - }).catch(self._reportError); + }).catch(self._service.reportError); } sendMessage(to, message) { - this._socket.write("" + xmlEncode(message) + ""); - } - - getConversations() { - return Object.keys(this._conversations); - } - - getParticipants(conversation) { - let result; - if (this._conversations[conversation]) { - result = this._conversations[conversation].participants; - } - return result; - } - - getHistory(conversation) { - let result; - if (this._conversations[conversation]) { - result = this._conversations[conversation].history; - } - return result; - } - - invokeCallback(message) { - let self = this; - for (let i = self._callbacks.length - 1; i >= 0; i--) { - let callback = self._callbacks[i]; - try { - callback(message); - } catch (error) { - self._callbacks.splice(i, 1); - - // XXX: Send it to the other connections? - print(error); - } - } + this._socket.write("" + xmlEncode(message) + "").catch(this._service.reportError); } _connect(options) { @@ -755,9 +724,7 @@ class XmppService { let password = options.password; let server = options.server; self._socket.connect("jabber.troubleimpact.com", 5222).then(function() { - print("actually connected"); - self.invokeCallback({action: "connected"}); - print("wtf"); + self._service.notifyStateChanged("connected"); var parse = new XmlStanzaParser(1); self._socket.write(""); self._socket.write(""); @@ -768,7 +735,7 @@ class XmppService { self._socket.read(function(data) { try { if (!data) { - self.invokeCallback({action: "disconnected"}); + self._service.notifyStateChanged("disconnected"); return; } parse.parse(data).forEach(function(stanza) { @@ -800,19 +767,26 @@ class XmppService { } else if (stanza.attributes.id == "session0") { self._socket.write("1"); self._schedulePing(); - self._conversations["chadhappyfuntime@conference.jabber.troubleimpact.com"] = {participants: [], history: []}; + //self._conversations["chadhappyfuntime@conference.jabber.troubleimpact.com"] = {participants: [], history: []}; } else if (stanza.children.length && stanza.children[0].name == "ping") { // Ping response. } else { - self.invokeCallback({ - action: "unknown", - stanza: stanza, - }); + self._service.notifyMessageReceived(null, {unknown: stanza}); } } else if (stanza.name == "message") { let message = self._convertMessage(stanza); - self._conversations[message.conversation].history.push(message); - self.invokeCallback(message); + let conversation = stanza.attributes.from; + if (conversation && conversation.indexOf('/') != -1) { + conversation = conversation.split("/")[1]; + } + if (stanza.attributes.type == "groupchat") { + if (self._service.isConversation(stanza.attributes.to.split("/")[0])) { + conversation = stanza.attributes.to.split("/")[0]; + } else if (self._service.isConversation(stanza.attributes.from.split("/")[0])) { + conversation = stanza.attributes.from.split("/")[0]; + } + } + self._service.notifyMessageReceived(conversation, message); } else if (stanza.name == "challenge") { var challenge = Base64.decode(stanza.text); var parts = challenge.split(','); @@ -849,33 +823,16 @@ class XmppService { } else if (stanza.name == "presence") { let name = stanza.attributes.from.split('/', 2)[1]; let conversation = stanza.attributes.from.split('/', 2)[0]; - let leaving = stanza.attributes.type == "unavailable"; - let index = self._conversations[conversation].participants.indexOf(name); - if (leaving) { - self._conversations[conversation].participants.splice(index, 1); - } else { - if (index == -1) { - self._conversations[conversation].participants.push(name); - } - } - self.invokeCallback({ - action: "presence", - name: name, - jid: stanza.attributes.from, - type: stanza.attributes.type, - }); + self._service.notifyPresenceChanged(conversation, name, stanza.attributes.type); } else { - self.invokeCallback({ - action: "unknown", - stanza: stanza, - }); + self._service.notifyMessageReceived(null, {unknown: stanza}); } }); } catch (error) { - self._reportError(error); + self._service.reportError(error); } }); - }).catch(self._reportError); + }).catch(self._service.reportError); } disconnect() { @@ -884,15 +841,6 @@ class XmppService { delete gSessions[self._name]; } - _reportError(error) { - this.invokeCallback({ - action: "error", - error: error, - }).catch(function(error) { - print(error); - }); - } - _convertMessage(stanza) { let self = this; let text; @@ -909,18 +857,8 @@ class XmppService { if (from && from.indexOf('/') != -1) { from = from.split("/")[1]; } - let conversation = from; - if (stanza.attributes.type == "groupchat") { - if (self._conversations[stanza.attributes.to.split("/")[0]]) { - conversation = stanza.attributes.to.split("/")[0]; - } else if (self._conversations[stanza.attributes.from.split("/")[0]]) { - conversation = stanza.attributes.from.split("/")[0]; - } - } let message = { - action: "message", from: from, - conversation: conversation, message: text, stanza: stanza, timestamp: now, @@ -937,23 +875,4 @@ class XmppService { } }; -let gSessions = {}; - -core.register("onMessage", function(sender, options) { - let service = gSessions[options.name]; - if (!service) { - service = new XmppService(options); - gSessions[options.name] = service; - } else { - if (service._callbacks.indexOf(options.callback) == -1) { - service._callbacks.push(options.callback); - } - } - return { - sendMessage: service.sendMessage.bind(service), - getConversations: service.getConversations.bind(service), - getHistory: service.getHistory.bind(service), - getParticipants: service.getParticipants.bind(service), - disconnect: service.disconnect.bind(service), - }; -}); \ No newline at end of file +ChatService.handleMessages(XmppService); \ No newline at end of file