diff --git a/social/xmpp/92-xmpp.html b/social/xmpp/92-xmpp.html index 92b0f3f3..3de2bb25 100644 --- a/social/xmpp/92-xmpp.html +++ b/social/xmpp/92-xmpp.html @@ -133,8 +133,8 @@ RED.nodes.registerType('xmpp-server',{ category: 'config', defaults: { - // server: {required:true}, - // port: {value:5222,required:true,validate:RED.validators.number()}, + server: {required:false}, + port: {value:5222,required:false,validate:RED.validators.number()}, user: {type:"text"}, nickname: {value:""} }, diff --git a/social/xmpp/92-xmpp.js b/social/xmpp/92-xmpp.js index 4e302aca..501e37e2 100644 --- a/social/xmpp/92-xmpp.js +++ b/social/xmpp/92-xmpp.js @@ -1,52 +1,163 @@ module.exports = function(RED) { "use strict"; - var XMPP = require('simple-xmpp'); + const {client, xml, jid} = require('@xmpp/client') process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' + const LOGITALL=false; function XMPPServerNode(n) { RED.nodes.createNode(this,n); - this.server = n.server; - this.port = n.port; this.nickname = n.nickname; - this.username = n.user; + this.jid = n.user; + this.username = n.user.split('@')[0]; + // The user may elect to just specify the jid in the settings, + // in which case extract the server from the jid and default the port + if("undefined" === typeof n.server || n.server === ""){ + this.server = n.user.split('@')[1]; + } + else{ + this.server = n.server; + } + if("undefined" === typeof n.port || n.port === ""){ + this.port = 5222; + } + else{ + this.port = n.port; + } + + // The password is obfuscated and stored in a separate location var credentials = this.credentials; if (credentials) { this.password = credentials.password; } - this.client = new XMPP.SimpleXMPP(); + // The basic xmpp client object, this will be referred to as "xmpp" in the nodes. + // note we're not actually connecting here. + this.client = client({ + service: 'xmpp://' + this.server + ':' + this.port, + username: this.username, + password: this.password + }); + + // helper variable for checking against later, maybe we should be using the client + // object directly... this.connected = false; + // store the nodes that have us as config so we know when to tear it all down. + this.users = {}; + // helper variable, because "this" changes definition inside a callback var that = this; - this.client.con = function() { - if (that.connected === false ) { - that.connected = true; - that.client.connect({ - jid : that.username, - password : that.password, - host : that.server, - port : that.port, - //skipPresence : true, - reconnect : true, - preferred : "PLAIN" - }); + // function for a node to tell us it has us as config + this.register = function(xmppThat) { + if (RED.settings.verbose || LOGITALL) {that.log("registering "+xmppThat.id);} + that.users[xmppThat.id] = xmppThat; + // So we could start the connection here, but we already have the logic in the thats. + // if (Object.keys(that.users).length === 1) { + // this.client.start(); + // } + }; + + // function for a node to tell us it's not using us anymore + this.deregister = function(xmppThat,done) { + if (RED.settings.verbose || LOGITALL) {that.log("deregistering "+xmppThat.id);} + delete that.users[xmppThat.id]; + if (that.closing) { + return done(); } + if (Object.keys(that.users).length === 0) { + if (that.client && that.client.connected) { + return that.client.stop(done); + } else { + that.client.stop(); + return done(); + } + } + done(); + }; + + // store the last node to use us, in case we get an error back + this.lastUsed = undefined; + // function for a node to tell us it has just sent a message to our server + // so we know which node to blame if it all goes Pete Tong + this.used = function(xmppThat){ + if (RED.settings.verbose || LOGITALL) {that.log(xmppThat.id+" sent a message to the xmpp server");} + that.lastUsed = xmppThat; } - that.client.on('online', function(data) { + + // Some errors come back as a message :-( + // this means we need to figure out which node might have sent it + // we also deal with subscriptions (i.e. presence information) here + this.client.on('stanza', async (stanza) =>{ + if (stanza.is('message')) { + if (stanza.attrs.type == 'error') { + if (RED.settings.verbose || LOGITALL) { + that.log("Received error"); + that.log(stanza); + } + var err = stanza.getChild('error'); + if(err){ + var textObj = err.getChild('text'); + var text = "node-red:common.status.error"; + if("undefined" !== typeof textObj){ + text = textObj.getText(); + } + if (RED.settings.verbose || LOGITALL) {that.log("Culprit: "+that.lastUsed);} + if("undefined" !== typeof that.lastUsed){ + that.lastUsed.status({fill:"red",shape:"ring",text:text}); + that.lastUsed.warn(text); + } + // maybe throw the message or summit + //that.error(text); + } + } + } + else if(stanza.is('presence')){ + if(['subscribe','subscribed','unsubscribe','unsubscribed'].indexOf(stanza.attrs.type) > -1){ + if (RED.settings.verbose || LOGITALL) {that.log("got a subscription based message");} + switch(stanza.attrs.type){ + case 'subscribe': + // they're asking for permission let's just say yes + var response = xml('presence', + {type:'subscribed', to:stanza.attrs.from}); + // if an error comes back we can't really blame anyone else + that.used(that); + that.client.send(response); + break; + default: + that.log("Was told we've "+stanza.attrs.type+" from "+stanza.attrs.from+" but we don't really care"); + } + } + } + }); + // We shouldn't have any errors here that the input/output nodes can't handle + // if you need to see everything though; uncomment this block + // this.client.on('error', err => { + // that.warn(err); + // that.warn(err.stack); + // }); + + // this gets called when we've completed the connection + this.client.on('online', async address => { + // provide some presence so people can see we're online that.connected = true; - that.client.setPresence('online', data.jid.user+' is online'); - that.log('connected as '+data.jid.user+' to '+data.jid._domain+":5222"); + await that.client.send(xml('presence')); + // await that.client.send(xml('presence', {type: 'available'},xml('status', {}, 'available'))); + if (RED.settings.verbose || LOGITALL) {that.log('connected as '+that.username+' to ' +that.server+':'+that.port);} }); - that.client.on('close', function() { + + // if the connection has gone away, not sure why! + this.client.on('offline', () => { that.connected = false; - that.log('connection closed'); + if (RED.settings.verbose || LOGITALL) {that.log('connection closed');} }); - this.on("close", function(done) { - that.client.setPresence('offline'); - that.client.disconnect(); - if (that.client.conn) { that.client.conn.end(); } - that.client = null; + + // gets called when the node is destroyed, e.g. if N-R is being stopped. + this.on("close", async done => { + if(that.connected){ + await that.client.send(xml('presence', {type: 'unavailable'})); + } + that.client.stop().catch(that.error); + done(); }); } @@ -68,31 +179,95 @@ module.exports = function(RED) { var node = this; var xmpp = this.serverConfig.client; + + /* connection states + online: We are connected + offline: disconnected and will not autoretry + connecting: Socket is connecting + connect: Socket is connected + opening: Stream is opening + open: Stream is open + closing: Stream is closing + close: Stream is closed + disconnecting: Socket is disconnecting + disconnect: Socket is disconnected + */ - xmpp.on('online', function(data) { - node.status({fill:"green",shape:"dot",text:"connected"}); + xmpp.on('online', async address => { + node.status({fill:"green",shape:"dot",text:"node-red:common.status.connected"}); if ((node.join) && (node.from !== "")) { - // disable chat history var to = node.from+'/'+node.nick; - var stanza = new xmpp.Element('presence', {"to": to}). - c('x', { xmlns: 'http://jabber.org/protocol/muc' }). - c('history', { maxstanzas:0, seconds:1 }); - xmpp.conn.send(stanza); - xmpp.join(to); + // the presence with the muc x element signifies we want to join the muc + // if we want to support passwords, we need to add that as a child of the x element + // (third argument to the x/muc/children ) + // We also turn off chat history (maxstanzas 0) because that's not what this node is about. + var stanza = xml('presence', + {"to": to}, + xml("x",'http://jabber.org/protocol/muc'), + { maxstanzas:0, seconds:1 } + ); + node.serverConfig.used(node); + xmpp.send(stanza); } }); - // xmpp.on('chat', function(from, message) { - // var msg = { topic:from, payload:message }; - // if (!node.join && ((node.from === "") || (node.from === from))) { - // node.send([msg,null]); - // } - // }); + xmpp.on('connecting', async address => { + node.status({fill:"grey",shape:"dot",text:"node-red:common.status.connecting"}); + }); + xmpp.on('connect', async address => { + node.status({fill:"grey",shape:"dot",text:"node-red:common.status.connected"}); + }); + xmpp.on('opening', async address => { + node.status({fill:"grey",shape:"dot",text:"opening"}); + }); + xmpp.on('open', async address => { + node.status({fill:"grey",shape:"dot",text:"open"}); + }); + xmpp.on('closing', async address => { + node.status({fill:"grey",shape:"dot",text:"closing"}); + }); + xmpp.on('close', async address => { + node.status({fill:"grey",shape:"dot",text:"closed"}); + }); + xmpp.on('disconnecting', async address => { + node.status({fill:"grey",shape:"dot",text:"disconnecting"}); + }); + // we'll not add a offline catcher, as the error catcher should populate the status for us + + // Should we listen on other's status (chatstate) or a chatroom state (groupbuddy)? + xmpp.on('error', err => { + if (RED.settings.verbose || LOGITALL) { node.log(err); } + if (err.hasOwnProperty("stanza")) { + if (err.stanza.name === 'stream:error') { node.error("stream:error - bad login id/pwd ?",err); } + else { node.error(err.stanza.name,err); } + node.status({fill:"red",shape:"ring",text:"bad login"}); + } + else { + if (err.errno === "ETIMEDOUT") { + node.error("Timeout connecting to server",err); + node.status({fill:"red",shape:"ring",text:"timeout"}); + } + if (err.errno === "ENOTFOUND") { + node.error("Server doesn't exist "+xmpp.options.service,err); + node.status({fill:"red",shape:"ring",text:"bad address"}); + } + else if (err === "XMPP authentication failure") { + node.error(err,err); + node.status({fill:"red",shape:"ring",text:"XMPP authentication failure"}); + } + else { + node.error(err.errno,err); + node.status({fill:"red",shape:"ring",text:"node-red:common.status.error"}); + } + } + }); - xmpp.on('stanza', function(stanza) { + // Meat of it, a stanza object contains chat messages (and other things) + xmpp.on('stanza', async (stanza) =>{ + // node.log("Received stanza"); + if (RED.settings.verbose || LOGITALL) {node.log(stanza);} if (stanza.is('message')) { if (stanza.attrs.type == 'chat') { - //console.log(stanza); var body = stanza.getChild('body'); if (body) { var msg = { payload:body.getText() }; @@ -106,74 +281,85 @@ module.exports = function(RED) { } } } + else if(stanza.attrs.type == 'groupchat'){ + const parts = stanza.attrs.from.split("/"); + var conference = parts[0]; + var from = parts[1]; + var body = stanza.getChild('body'); + var payload = ""; + if("undefined" !== typeof body){ + payload = body.getText(); + } + var msg = { topic:from, payload:payload, room:conference }; + if (stanza.attrs.from != node.nick) { + if ((node.join) && (node.from === conference)) { + node.send([msg,null]); + } + } + } } - }); + else if(stanza.is('presence')){ + if(['subscribe','subscribed','unsubscribe','unsubscribed'].indexOf(stanza.attrs.type) > -1){ + // this isn't for us, let the config node deal with it. - xmpp.on('groupchat', function(conference, from, message, stamp) { - var msg = { topic:from, payload:message, room:conference }; - if (from != node.nick) { - if ((node.join) && (node.from === conference)) { - node.send([msg,null]); + } + else{ + var statusText=""; + if(stanza.attrs.type === 'unavailable'){ + // the user might not exist, but the server doesn't tell us that! + statusText = "offline"; + } + var status = stanza.getChild('status'); + if("undefined" !== typeof status){ + statusText = status.getText(); + } + // right, do we care if there's no status? + if(statusText !== ""){ + var from = stanza.attrs.from; + var state = stanza.attrs.show; + var msg = {topic:from, payload: {presence:state, status:statusText} }; + node.send([null,msg]); + } + else{ + if (RED.settings.verbose || LOGITALL) { + node.log("not propagating blank status"); + node.log(stanza); + } + } } } }); - //xmpp.on('chatstate', function(from, state) { - //console.log('%s is currently %s', from, state); - //var msg = { topic:from, payload: {presence:state} }; - //node.send([null,msg]); - //}); - xmpp.on('buddy', function(jid, state, statusText) { - node.log(jid+" is "+state+" : "+statusText); - var msg = { topic:jid, payload: { presence:state, status:statusText} }; - node.send([null,msg]); - }); - // xmpp.on('groupbuddy', function(conference, from, state, statusText) { - // //console.log('%s: %s is in %s state - %s',conference, from, state, statusText); - // var msg = { topic:from, payload: { presence:state, status:statusText}, room:conference }; + // xmpp.on('subscribe', from => { + // xmpp.acceptSubscription(from); // }); - xmpp.on('error', function(err) { - if (RED.settings.verbose) { node.log(err); } - if (err.hasOwnProperty("stanza")) { - if (err.stanza.name === 'stream:error') { node.error("stream:error - bad login id/pwd ?",err); } - else { node.error(err.stanza.name,err); } - node.status({fill:"red",shape:"ring",text:"bad login"}); - } - else { - if (err.errno === "ETIMEDOUT") { - node.error("Timeout connecting to server",err); - node.status({fill:"red",shape:"ring",text:"timeout"}); - } - else if (err === "XMPP authentication failure") { - node.error(err,err); - node.status({fill:"red",shape:"ring",text:"XMPP authentication failure"}); - } - else { - node.error(err.errno,err); - node.status({fill:"red",shape:"ring",text:"error"}); - } - } - }); - - xmpp.on('subscribe', function(from) { - xmpp.acceptSubscription(from); - }); - + //register with config + this.serverConfig.register(this); // Now actually make the connection try { - node.status({fill:"grey",shape:"dot",text:"connecting"}); - xmpp.con(); + if(xmpp.status === "online"){ + node.status({fill:"green",shape:"dot",text:"node-red:common.status.connected"}); + } + else{ + node.status({fill:"grey",shape:"dot",text:"node-red:common.status.connecting"}); + if(xmpp.status === "offline"){ + xmpp.start(); + } + } } catch(e) { - node.error("Bad xmpp configuration"); - node.status({fill:"red",shape:"ring",text:"not connected"}); + node.error("Bad xmpp configuration; service: "+xmpp.options.service+" jid: "+node.serverConfig.jid); + node.warn(e); + node.warn(e.stack); + node.status({fill:"red",shape:"ring",text:"node-red:common.status.disconnected"}); } - node.on("close", function() { - node.status({}); + node.on("close", function(removed, done) { + node.status({fill:"red",shape:"ring",text:"node-red:common.status.disconnected"}); + node.serverConfig.deregister(node, done); }); } RED.nodes.registerType("xmpp in",XmppInNode); @@ -191,21 +377,63 @@ module.exports = function(RED) { var xmpp = this.serverConfig.client; + /* connection states + online: We are connected + offline: disconnected and will not autoretry + connecting: Socket is connecting + connect: Socket is connected + opening: Stream is opening + open: Stream is open + closing: Stream is closing + close: Stream is closed + disconnecting: Socket is disconnecting + disconnect: Socket is disconnected + */ + xmpp.on('online', function(data) { - node.status({fill:"green",shape:"dot",text:"connected"}); + node.status({fill:"green",shape:"dot",text:"node-red:common.status.connected"}); if ((node.join) && (node.from !== "")) { // disable chat history var to = node.to+'/'+node.nick; - var stanza = new xmpp.Element('presence', {"to": to}). - c('x', { xmlns: 'http://jabber.org/protocol/muc' }). - c('history', { maxstanzas:0, seconds:1 }); - xmpp.conn.send(stanza); - xmpp.join(to); + // the presence with the muc x element signifies we want to join the muc + // if we want to support passwords, we need to add that as a child of the x element + // (third argument to the x/muc/children ) + // We also turn off chat history (maxstanzas 0) because that's not what this node is about. + var stanza = xml('presence', + {"to": to}, + xml("x",'http://jabber.org/protocol/muc'), + { maxstanzas:0, seconds:1 } + ); + node.serverConfig.used(node); + xmpp.send(stanza); } }); + xmpp.on('connecting', async address => { + node.status({fill:"grey",shape:"dot",text:"node-red:common.status.connecting"}); + }); + xmpp.on('connect', async address => { + node.status({fill:"grey",shape:"dot",text:"node-red:common.status.connected"}); + }); + xmpp.on('opening', async address => { + node.status({fill:"grey",shape:"dot",text:"opening"}); + }); + xmpp.on('open', async address => { + node.status({fill:"grey",shape:"dot",text:"open"}); + }); + xmpp.on('closing', async address => { + node.status({fill:"grey",shape:"dot",text:"closing"}); + }); + xmpp.on('close', async address => { + node.status({fill:"grey",shape:"dot",text:"closed"}); + }); + xmpp.on('disconnecting', async address => { + node.status({fill:"grey",shape:"dot",text:"disconnecting"}); + }); + // we'll not add a offline catcher, as the error catcher should populate the status for us + xmpp.on('error', function(err) { - if (RED.settings.verbose) { node.log(err); } + if (RED.settings.verbose || LOGITALL) { node.log(err); } if (err.hasOwnProperty("stanza")) { if (err.stanza.name === 'stream:error') { node.error("stream:error - bad login id/pwd ?",err); } else { node.error(err.stanza.name,err); } @@ -216,54 +444,98 @@ module.exports = function(RED) { node.error("Timeout connecting to server",err); node.status({fill:"red",shape:"ring",text:"timeout"}); } + if (err.errno === "ENOTFOUND") { + node.error("Server doesn't exist "+xmpp.options.service,err); + node.status({fill:"red",shape:"ring",text:"bad address"}); + } else if (err === "XMPP authentication failure") { node.error(err,err); node.status({fill:"red",shape:"ring",text:"XMPP authentication failure"}); } else { node.error(err.errno,err); - node.status({fill:"red",shape:"ring",text:"error"}); + node.status({fill:"red",shape:"ring",text:"node-red:common.status.error"}); } } }); + //register with config + this.serverConfig.register(this); // Now actually make the connection - try { - node.status({fill:"grey",shape:"dot",text:"connecting"}); - xmpp.con(); + if(xmpp.status === "online"){ + node.status({fill:"green",shape:"dot",text:"online"}); } - catch(e) { - node.error("Bad xmpp configuration"); - node.status({fill:"red",shape:"ring",text:"not connected"}); + else{ + node.status({fill:"grey",shape:"dot",text:"node-red:common.status.connecting"}); + if(xmpp.status === "offline"){ + xmpp.start().catch(error => { + node.error("Bad xmpp configuration; service: "+xmpp.options.service+" jid: "+node.serverConfig.jid); + node.warn(error); + node.warn(error.stack); + node.status({fill:"red",shape:"ring",text:"node-red:common.status.error"}); + }); + } } + // Let's get down to business and actually send a message node.on("input", function(msg) { if (msg.presence) { if (['away', 'dnd', 'xa', 'chat'].indexOf(msg.presence) > -1 ) { - xmpp.setPresence(msg.presence, msg.payload); + var stanza = xml('presence', + {"show":msg.presence}, + xml('status',{},msg.payload)); + node.serverConfig.used(node); + xmpp.send(stanza); } else { node.warn("Can't set presence - invalid value: "+msg.presence); } } + else if(msg.command){ + if(msg.command === "subscribe"){ + var stanza = xml('presence', + {type:'subscribe', to: msg.payload}); + node.serverConfig.used(node); + xmpp.send(stanza); + } + } else { var to = node.to || msg.topic || ""; if (to !== "") { + var message; + var type = node.join? "groupchat":"chat"; if (node.sendAll) { - xmpp.send(to, JSON.stringify(msg), node.join); + message = xml( + "message", + { type: type, to: to }, + xml("body", {}, JSON.stringify(msg)) + ); + } else if (msg.payload) { if (typeof(msg.payload) === "object") { - xmpp.send(to, JSON.stringify(msg.payload), node.join); + message = xml( + "message", + { type: type, to: to }, + xml("body", {}, JSON.stringify(msg.payload)) + ); } else { - xmpp.send(to, msg.payload.toString(), node.join); + message = xml( + "message", + { type: type, to: to }, + xml("body", {}, msg.payload.toString()) + ); } } + node.serverConfig.used(node); + xmpp.send(message); } } }); - node.on("close", function() { - node.status({}); + node.on("close", function(removed, done) { + if (RED.settings.verbose || LOGITALL) {node.log("Closing");} + node.status({fill:"red",shape:"ring",text:"node-red:common.status.disconnected"}); + node.serverConfig.deregister(node, done); }); } RED.nodes.registerType("xmpp out",XmppOutNode); diff --git a/social/xmpp/package.json b/social/xmpp/package.json index a37f3b5d..2225f826 100644 --- a/social/xmpp/package.json +++ b/social/xmpp/package.json @@ -1,18 +1,21 @@ { - "name" : "node-red-node-xmpp", - "version" : "0.2.4", - "description" : "A Node-RED node to talk to an XMPP server", - "dependencies" : { - "simple-xmpp" : "^1.3.1" + "name": "node-red-node-xmpp", + "version": "0.2.4", + "description": "A Node-RED node to talk to an XMPP server", + "dependencies": { + "@xmpp/client": "^0.11.1" }, - "repository" : { - "type":"git", - "url":"https://github.com/node-red/node-red-nodes/tree/master/social/xmpp" + "repository": { + "type": "git", + "url": "https://github.com/node-red/node-red-nodes/tree/master/social/xmpp" }, "license": "Apache-2.0", - "keywords": [ "node-red", "xmpp" ], - "node-red" : { - "nodes" : { + "keywords": [ + "node-red", + "xmpp" + ], + "node-red": { + "nodes": { "xmpp": "92-xmpp.js" } },