diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..5c2bf3f --- /dev/null +++ b/docs/README.md @@ -0,0 +1,84 @@ +# Visual bot creation using Node-RED + +It's very easy to create simple interactions with a [Matrix](https://matrix.org) chatroom without programming. Discover [Node-RED](http://nodered.org/), a visual tool to wire together APIs. We have extended Node-RED with nodes to listen and talk in Matrix chatrooms. + +![Sample Node-RED application](nodered-twitter.png) + +## How to install Node-RED + +Node-RED is an open-source web application that is easy to deploy as a Docker container. You can deploy it locally on your computer using Docker. Or you can use any of the available online Docker hosting offers, such as [sloppy.io](https://sloppy.io/) or [hyper_](https://hyper.sh/). + +The custom Docker image which includes Node-RED plus additional nodes is available on our [DockerHub repository](https://hub.docker.com/r/maski/node-red-docker/) + +### Install Node-RED on your local machine + +First, [install Docker](http://www.docker.com/products/docker) if you haven't done it yet. + +Then you just need to run: + + docker run -it -p 1880:1880 --name mynodered maski/node-red-docker + +Let's dissect that command... + + docker run - run this container... and build locally if necessary first. + -it - attach a terminal session so we can see what is going on + -p 1880:1880 - connect local port 1880 to the exposed internal port 1880 + --name mynodered - give this machine a friendly local name + maski/node-red-docker - the image to base it on + + +Running that command should give a terminal window with a running instance of Node-RED + + Welcome to Node-RED + =================== + 8 Apr 12:13:44 - [info] Node-RED version: v0.14.5 + 8 Apr 12:13:44 - [info] Node.js version: v4.4.7 + .... etc + +You can then browse to `http://{host-ip}:1880` to get the familiar Node-RED desktop. + + +### Install Node-RED on an online Docker hosting + +A quick and easy way to host it online is to use Sloppy.io's 1-month free trial. +Once you have created an account, go to the Dashboard and create a new project, a new service inside it, and an app within it using `maski/node-red-docker` as image path and pick a domain URI, such as `my-node-red.sloppy.zone`. Deploy the project, and you will have your own Node-RED server up and running at the selected URI. + +## How to use Node-RED + +You can learn how to use Node-RED by following the [Node-RED getting started guide](http://nodered.org/docs/getting-started/first-flow). You can find additional tutorials [here](http://noderedguide.com/). + +## How to use the Matrix nodes in Node-RED + +The package `node-red-contrib-matrixbot`, included in the abovementioned Docker image, adds 3 node types to the Node-RED palette (Matrix section, at the bottom): +* *Matrix sender*: sends messages from your Node-RED flow to the chatroom +* *Matrix receiver*: listens to messages in a chatroom and sends them to your Node-RED flow +* *Matrix command*: listens only to messages starting with a specific command and sends them to your Node-RED flow + +All of these nodes require a Matrix Configuration with the following settings: + +* *User ID*: the user ID in the matrix server, for instance @mybot:matrix.org +* *Access token*: the access token of the user in the matrix server +* *Server URL*: URL of the Matrix homeserver, e.g. https://matrix.org +* *Room ID*: ID of the chatroom to join when starting. If no room is specified, it will automatically join any room where it is invited + +## A simple application + +We will create a simple application that will send messages to a chatroom whenever a RSS feed gets updated: + +* First, invite the bot to the chatroom where it will be speaking and note the room ID. +* In Node-RED, pick the *Feedparse* node from the palette (you can filter by typing the first letters at the top) and drop it on the canvas +* Double-click on it to configure it and then enter a RSS feed, for instance `http://rss.nytimes.com/services/xml/rss/nyt/World.xml`. Give the node a name if you want and then click *Done*. +* Pick the *Matrix sender* node and drop it on the canvas. Link the output of the previous node to its input. +* Now double click on it and click on the pencil next to *Connection* to configure the Matrix settings. Follow the instructions described in the previous section. +* Once you're done, click the *Deploy* button at the top right corner and you're done! +* If you want to show also the link in the resulting message, try adding a *function* node in the middle with the following code: +``` +var title = msg.article.title; +var link = msg.topic; +msg.payload = title + " \n" + link; +return msg; +``` + +![Sample Node-RED application](nodered-rss.png) + +![ ](https://ga-beacon.appspot.com/UA-63227151-9/docs/README.md?pixel) \ No newline at end of file diff --git a/docs/nodered-rss.png b/docs/nodered-rss.png new file mode 100644 index 0000000..1f4b186 Binary files /dev/null and b/docs/nodered-rss.png differ diff --git a/docs/nodered-twitter.png b/docs/nodered-twitter.png new file mode 100644 index 0000000..e61ba98 Binary files /dev/null and b/docs/nodered-twitter.png differ diff --git a/matrixbot/99-matrixbot.html b/matrixbot/99-matrixbot.html new file mode 100644 index 0000000..32c00cb --- /dev/null +++ b/matrixbot/99-matrixbot.html @@ -0,0 +1,182 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/matrixbot/99-matrixbot.js b/matrixbot/99-matrixbot.js new file mode 100644 index 0000000..1885258 --- /dev/null +++ b/matrixbot/99-matrixbot.js @@ -0,0 +1,303 @@ +module.exports = function(RED) { + + "use strict"; + + var sdk = require("matrix-js-sdk"); + var md = require("markdown-it")(); + +// -------------------------------------------------------------------------------------------- + // The configuration node holds the configuration and credentials for all nodes. + + function MatrixBotNode(config) { + RED.nodes.createNode(this, config); + + // copy "this" object in case we need it in context of callbacks of other functions. + var node = this; + + node.log("Initializing Matrix Bot node"); + + // Configuration options passed by Node Red + node.userId = config.userId; + node.room = config.room; + + // TODO: Switch from configuration to credentials and check with if (this.credentials) + node.accessToken = config.accessToken; + node.matrixServerURL = config.matrixServerURL; + + node.matrixClient = sdk.createClient({ + baseUrl: node.matrixServerURL, + accessToken: node.accessToken, + userId: node.userId + }); + + // If no room is specified, join any room where we are invited + if (!node.room || node.room === "") { + node.matrixClient.on("RoomMember.membership", function(event, member) { + if (member.membership === "invite" && member.userId === node.userId) { + node.log("Trying to join room " + member.roomId); + node.matrixClient.joinRoom(member.roomId).then(function() { + node.log("Automatically accepted invitation to join room " + member.roomId); + }).catch(function(e) { + node.warn("Cannot join room (probably because I was kicked) " + member.roomId + ": " + e); + }); + } + }); + } + + node.matrixClient.on("sync", function(state, prevState, data) { + switch (state) { + case "ERROR": + // update UI to say "Connection Lost" + node.warn("Connection to Matrix server lost"); + node.updateConnectionState(false); + break; + case "SYNCING": + // update UI to remove any "Connection Lost" message + node.updateConnectionState(true); + break; + case "PREPARED": + // the client instance is ready to be queried. + node.log("Synchronized to Matrix server."); + + if (node.room) { + node.log("Trying to join room " + node.room); + + node.matrixClient.joinRoom(node.room, {syncRoom:false}) + .then(function(joinedRoom) { + node.log("Joined " + node.room); + node.room = joinedRoom.roomId; + node.updateConnectionState(true); + }).catch(function(e) { + node.warn("Error joining " + node.room + ": " + e); + }); + } else { + node.log("No room configured. Will only join rooms where I'm invited"); + } + break; + } + }); + + node.log("Connecting to Matrix server..."); + + node.matrixClient.startClient(); + + // Called when the connection state may have changed + this.updateConnectionState = function(connected){ + if (node.connected !== connected) { + node.connected = connected; + if (connected) { + node.emit("connected"); + } else { + node.emit("disconnected"); + } + } + }; + + // When Node-RED updates nodes, disconnect from server to ensure a clean start + node.on("close", function (done) { + node.log("Matrix configuration node closing..."); + if (node.matrixClient) { + node.log("Disconnecting from Matrix server..."); + node.matrixClient.stopClient(); + node.updateConnectionState(false); + } + done(); + }); + + } + + RED.nodes.registerType("matrix bot", MatrixBotNode); + +// -------------------------------------------------------------------------------------------- + // The output node sends a message to the chat. + + function MatrixOutNode(config) { + RED.nodes.createNode(this, config); + + // copy "this" object in case we need it in context of callbacks of other functions. + var node = this; + + // Configuration options passed by Node Red + node.configNode = RED.nodes.getNode(config.bot); + + node.configNode.on("connected", function(){ + node.status({ fill: "green", shape: "ring", text: "connected" }); + }); + + node.configNode.on("disconnected", function(){ + node.status({ fill: "red", shape: "ring", text: "disconnected" }); + }); + + this.on("input", function (msg) { + if (! node.configNode || ! node.configNode.matrixClient) { + node.warn("No configuration"); + return; + } + + if (msg.payload) { + node.log("Sending message " + msg.payload); + + var destRoom = ""; + if (msg.roomId) { + destRoom = msg.roomId; + } else if (node.configNode.room) { + destRoom = node.configNode.room; + } else { + node.warn("Room must be specified in msg.roomId or in configuration"); + return; + } + + node.configNode.matrixClient.sendHtmlMessage(destRoom, msg.payload.toString(), md.render(msg.payload.toString())) + .then(function() { + node.log("Message sent: " + msg.payload); + }).catch(function(e){ + node.warn("Error sending message " + e); + }); + } else { + node.warn("msg.payload is empty"); + } + }); + + this.on("close", function(done) { + node.log("Matrix out node closing..."); + done(); + }); + } + + RED.nodes.registerType("matrix sender", MatrixOutNode); + + +// -------------------------------------------------------------------------------------------- + // The input node receives messages from the chat. + + function MatrixInNode(config) { + RED.nodes.createNode(this, config); + + // copy "this" object in case we need it in context of callbacks of other functions. + var node = this; + node.configNode = RED.nodes.getNode(config.bot); + + node.log("MatrixInNode initializing..."); + + if (!node.configNode) { + node.warn("No configuration node"); + return; + } + + node.status({ fill: "red", shape: "ring", text: "disconnected" }); + + node.configNode.on("disconnected", function(){ + node.status({ fill: "red", shape: "ring", text: "disconnected" }); + }); + + node.configNode.on("connected", function() { + node.status({ fill: "green", shape: "ring", text: "connected" }); + node.configNode.matrixClient.on("Room.timeline", function(event, room, toStartOfTimeline, data) { + if (toStartOfTimeline) { + return; // don't print paginated results + } + if (event.getType() !== "m.room.message") { + return; // only keep messages + } + if (!event.getSender() || event.getSender() === node.configNode.userId) { + return; // ignore our own messages + } + if (!event.getUnsigned() || event.getUnsigned().age > 1000) { + return; // ignore old messages + } + // TODO process messages other than text + node.log( + // the room name will update with m.room.name events automatically + "Received chat message: (" + room.name + ") " + event.getSender() + " :: " + event.getContent().body + ); + var msg = { + payload: event.getContent().body, + sender: event.getSender(), + roomId: room.roomId + }; + node.send(msg); + }); + }); + + this.on("close", function(done) { + node.log("Matrix in node closing..."); + done(); + }); + + } + + RED.nodes.registerType("matrix receiver", MatrixInNode); + +// -------------------------------------------------------------------------------------------- + // The command node receives messages from the chat. + + function MatrixCommandNode(config) { + RED.nodes.createNode(this, config); + + // copy "this" object in case we need it in context of callbacks of other functions. + var node = this; + node.command = config.command; + node.configNode = RED.nodes.getNode(config.bot); + + node.log("MatrixCommandNode initializing..."); + + if (!node.configNode) { + node.warn("No configuration node"); + return; + } + + node.status({ fill: "red", shape: "ring", text: "disconnected" }); + + node.configNode.on("disconnected", function(){ + node.status({ fill: "red", shape: "ring", text: "disconnected" }); + }); + + node.configNode.on("connected", function() { + node.status({ fill: "green", shape: "ring", text: "connected" }); + node.configNode.matrixClient.on("Room.timeline", function(event, room, toStartOfTimeline, data) { + if (toStartOfTimeline) { + return; // don't print paginated results + } + if (event.getType() !== "m.room.message") { + return; // only keep messages + } + if (!event.getSender() || event.getSender() === node.configNode.userId) { + return; // ignore our own messages + } + if (!event.getUnsigned() || event.getUnsigned().age > 1000) { + return; // ignore old messages + } + // TODO process messages other than text + node.log( + // the room name will update with m.room.name events automatically + "Received chat message: (" + room.name + ") " + event.getSender() + " :: " + event.getContent().body + ); + + var message = event.getContent().body; + + var tokens = message.split(" "); + + if (tokens[0] == node.command) { + node.log("Recognized command " + node.command + " Processing..."); + var remainingText = message.replace(node.command, ""); + var msg = { + payload: remainingText, + sender: event.getSender(), + roomId: room.roomId, + originalMessage: message + }; + node.send([msg, null]); + } + + }); + }); + + this.on("close", function(done) { + node.log("Matrix command node closing..."); + done(); + }); + } + + RED.nodes.registerType("matrix command", MatrixCommandNode); + +} diff --git a/matrixbot/icons/matrix.png b/matrixbot/icons/matrix.png new file mode 100644 index 0000000..ecec1cc Binary files /dev/null and b/matrixbot/icons/matrix.png differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..f8d2cb0 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "node-red-contrib-matrixbot", + "version": "0.0.3", + "description": "Matrix bot nodes for Node-RED", + "dependencies": { + "matrix-js-sdk": "^2.0.0", + "markdown-it": "^8.4.1" + }, + "node-red": { + "nodes": { + "matrixbot": "matrixbot/99-matrixbot.js" + } + }, + "keywords": [ + "node-red", "matrix", "bot" + ], + "repository": { + "type": "git", + "url": "https://github.com/mlopezr/node-red-contrib-matrixbot" + }, + "author": "nobody@nowhere" +}