- Update version to 0.1.5

- Updated readme
- Support for e2ee is here! It's in beta as I am sure there are still things to do (such as adding a node for encrypting files as files currently are not encrypted).
- Added nodes for joining a room (and forcing users into a room), creating rooms, decrypting files, and inviting users to a room.
- matrix-synapse-register node name changed from "Synapse Register v1" to "Shared Secret Registration" to make it more self explanatory.
- matrix-receive node updated so that instead of selecting what events to ignore you select what events to listen on (this way it isn't a BC every time we add another event).
- matrix-receive now handles m.emote & m.sticker events
- matrix-server-config updated to now include the device ID and a checkbox to flag whether to enable e2ee support or not.
- matrix-synapse-create-edit-user.html updated to include link to the API docs'
- matrix-synapse-deactivate-user.html updated to include message about alternative way to deactivate users (in a way that is recoverable)
- matrix-synapse-register node does not need to display if connected or not since it users an entirely different API anyways
- matrix-synapse-users.html updated to include link to API docs
This commit is contained in:
Skylar Sadlier 2021-08-23 10:17:08 -06:00
parent aeb52518ad
commit 8bee386216
24 changed files with 16270 additions and 116 deletions

View File

@ -7,17 +7,24 @@ Matrix chat server client for [Node-RED](https://nodered.org/)
The following is supported from this package:
- Receive events from a room (messages, reactions, images, and files)
- Send Images/Files
- End-to-end encryption supported
- Receive events from a room (messages, reactions, images, and files) whether encrypted or not
- Send Images/Files (sending files to e2ee room doesn't currently encrypt them yet)
- Decrypt files in e2ee rooms
- Send HTML/Plain Text Message/Notice
- React to messages
- Register user's on closed registration Synapse servers using `registration_shared_secret` (Admin Only)
- List out users on a Synapse server (Admin Only)
- Get WhoIs info for a Synapse user (Admin Only)
- Add/Edit Synapse users using the v2 API (requires a pre-existing admin account)
- Deactivate users on Synapse servers (Admin Only)
- Get a user list from a room
- Kick user from room
- Ban user from room
- Join a room
- Create a room
- Invite to a room
- Synapse admin API to force add user to room (requires bot to be in same room already)
Therefore, you can easily build a bot, chat relay, or administrate your Matrix server from within [Node-RED](https://nodered.org/).

15172
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,12 @@
{
"name": "node-red-contrib-matrix-chat",
"version": "0.0.5",
"version": "0.1.5",
"description": "Matrix chat server client for Node-RED",
"dependencies": {
"got": "^11.8.2",
"isomorphic-webcrypto": "^2.3.8",
"matrix-js-sdk": "^12.2.0",
"olm": "https://packages.matrix.org/npm/olm/olm-3.1.4.tgz",
"utf8": "^3.0.0"
},
"node-red": {
@ -15,12 +17,17 @@
"matrix-send-file": "src/matrix-send-file.js",
"matrix-send-image": "src/matrix-send-image.js",
"matrix-react": "src/matrix-react.js",
"matrix-create-room": "src/matrix-create-room.js",
"matrix-invite-room": "src/matrix-invite-room.js",
"matrix-join-room": "src/matrix-join-room.js",
"matrix-crypt-file": "src/matrix-crypt-file.js",
"matrix-room-kick": "src/matrix-room-kick.js",
"matrix-room-ban": "src/matrix-room-ban.js",
"matrix-synapse-users": "src/matrix-synapse-users.js",
"matrix-synapse-register": "src/matrix-synapse-register.js",
"matrix-synapse-create-edit-user": "src/matrix-synapse-create-edit-user.js",
"matrix-synapse-deactivate-user": "src/matrix-synapse-deactivate-user.js",
"matrix-synapse-join-room": "src/matrix-synapse-join-room.js",
"matrix-whois-user": "src/matrix-whois-user.js",
"matrix-room-users": "src/matrix-room-users.js"
}
@ -40,5 +47,8 @@
"name": "Skylar Sadlier",
"url": "https://skylar.tech"
},
"license": "SEE LICENSE FILE"
"license": "SEE LICENSE FILE",
"devDependencies": {
"node-localstorage": "^2.2.1"
}
}

View File

@ -0,0 +1,72 @@
<script type="text/javascript">
RED.nodes.registerType('matrix-create-room', {
category: 'matrix',
color: '#00b7ca',
icon: "matrix.png",
outputLabels: ["success", "error"],
inputs: 1,
outputs: 2,
defaults: {
name: { value: null },
server: { value: "", type: "matrix-server-config" }
},
label: function() {
return this.name || "Create Room";
},
paletteLabel: 'Create Room'
});
</script>
<script type="text/html" data-template-name="matrix-create-room">
<div class="form-row">
<label for="node-input-name"><i class="icon-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Name">
</div>
<div class="form-row">
<label for="node-input-server"><i class="fa fa-user"></i> Matrix Server Config</label>
<input type="text" id="node-input-server">
</div>
<div class="form-tips" style="margin-bottom: 12px;">
User must be an admin to use this endpoint.
</div>
</script>
<script type="text/html" data-help-name="matrix-create-room">
<h3>Details</h3>
<p>
Create a room with the defined options in <code>msg.payload</code>
</p>
<a href="https://matrix-org.github.io/synapse/develop/admin_api/room_membership.html#edit-room-membership-api" target="_blank">Synapse API Endpoint Information</a>
<h3>Inputs</h3>
<dl class="message-properties">
<dt>msg.payload
<span class="property-type">string</span>
</dt>
<dd> a list of options to pass to the /createRoom API. <a href="https://matrix.org/docs/spec/client_server/r0.4.0#id266">Click here</a> for information about what can be passed.</dd>
<dt>msg.userId
<span class="property-type">string</span>
</dt>
<dd> User's ID that will be set into the room.</dd>
</dl>
<h3>Outputs</h3>
<ol class="node-ports">
<li>Success
<dl class="message-properties">
<dt>msg.topic <span class="property-type">string</span></dt>
<dd>The ID of the newly created room. Ex: <code>!h8zld9j31:example.com.</code></dd>
</dl>
</li>
<li>Error
<dl class="message-properties">
<dt>msg.error <span class="property-type">string</span></dt>
<dd>the error that occurred.</dd>
</dl>
</li>
</ol>
</script>

70
src/matrix-create-room.js Normal file
View File

@ -0,0 +1,70 @@
module.exports = function(RED) {
function MatrixCreateRoom(n) {
RED.nodes.createNode(this, n);
let node = this;
this.name = n.name;
this.server = RED.nodes.getNode(n.server);
if(!this.server) {
node.error('Server must be configured on the node.');
return;
}
this.encodeUri = function(pathTemplate, variables) {
for (const key in variables) {
if (!variables.hasOwnProperty(key)) {
continue;
}
pathTemplate = pathTemplate.replace(
key, encodeURIComponent(variables[key]),
);
}
return pathTemplate;
};
node.status({ fill: "red", shape: "ring", text: "disconnected" });
node.server.on("disconnected", function(){
node.status({ fill: "red", shape: "ring", text: "disconnected" });
});
node.server.on("connected", function() {
node.status({ fill: "green", shape: "ring", text: "connected" });
});
node.on("input", function (msg) {
if (! node.server || ! node.server.matrixClient) {
node.warn("No matrix server selected");
return;
}
if(!node.server.isConnected()) {
node.error("Matrix server connection is currently closed");
node.send([null, msg]);
}
if(!msg.payload) {
msg.payload = {};
} else if(typeof msg.payload === 'string') {
msg.payload = {
name: msg.payload
};
}
// we need the status code, so set onlydata to false for this request
node.server.matrixClient
.createRoom(msg.payload || {})
.then(function(e){
msg.topic = e.room_id;
node.send([msg, null]);
}).catch(function(e){
node.warn("Error creating room " + e);
msg.error = e;
node.send([null, msg]);
});
});
}
RED.nodes.registerType("matrix-create-room", MatrixCreateRoom);
}

View File

@ -0,0 +1,74 @@
<script type="text/javascript">
RED.nodes.registerType('matrix-decrypt-file',{
category: 'matrix',
color: '#00b7ca',
icon: "matrix.png",
outputLabels: ["success", "error"],
inputs:1,
outputs:2,
defaults: {
name: { value: null }
},
label: function() {
return this.name || "Decrypt File";
},
paletteLabel: 'Decrypt File'
});
</script>
<script type="text/html" data-template-name="matrix-decrypt-file">
<div class="form-row">
<label for="node-input-name"><i class="icon-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Name">
</div>
</script>
<script type="text/html" data-help-name="matrix-decrypt-file">
<h3>Details</h3>
<p>Files sent in an encrypted room are themselves encrypted. Use this node to encrypt/decrypt files. Note: This node will download the encryted file (required to decrypt)</p>
<h3>Inputs</h3>
<dl class="message-properties">
<dt>msg.content
<span class="property-type">Object</span>
</dt>
<dd> content of the decrypted message</dd>
<dt>msg.content.file
<span class="property-type">Object</span>
</dt>
<dd> the information needed to decode the file</dd>
<dt>msg.url
<span class="property-type">String | Null</span>
</dt>
<dd> the decoded mxc url.</dd>
</dl>
<h3>Outputs</h3>
<ol class="node-ports">
<li>Success
<dl class="message-properties">
<dt>msg.type <span class="property-type">string</span></dt>
<dd>The message type (ex: <code>m.file</code>, <code>m.image</code>, <code>m.video</code>, etc)</dd>
<dt>msg.payload <span class="property-type">buffer</span></dt>
<dd>decoded file contents.</dd>
<dt>msg.filename <span class="property-type">string</span></dt>
<dd>filename of the decoded file (if content.filename isn't defined on the message we fallback to content.body).</dd>
</dl>
</li>
<li>Error
<dl class="message-properties">
<dt>msg.error <span class="property-type">string</span></dt>
<dd>the error that occurred.</dd>
</dl>
</li>
</ol>
<h3>References</h3>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types">MIME Types</a> - description of <code>msg.contentType</code> format</li>
</ul>
</script>

202
src/matrix-crypt-file.js Normal file
View File

@ -0,0 +1,202 @@
module.exports = function(RED) {
const got = require('got');
const crypto = require('isomorphic-webcrypto');
function MatrixDecryptFile(n) {
RED.nodes.createNode(this, n);
var node = this;
this.name = n.name;
node.on("input", async function (msg) {
if(!msg.type) {
node.error('msg.type is required.');
return;
}
if(!msg.content) {
node.error('msg.content is required.');
return;
}
if(!msg.content.file) {
node.error('msg.content.file is required.');
return;
}
if(!msg.url) {
node.error('msg.url is required.');
return;
}
try{
let buffer = await got(msg.url).buffer();
msg.payload = Buffer.from(await decryptAttachment(buffer, msg.content.file));
// handle thumbnail decryption if necessary
if(
msg.type.toLowerCase() === 'm.image'
&& msg.thumbnail_url
&& msg.content.info.thumbnail_file
) {
let thumb_buffer = await got(msg.thumbnail_url).buffer();
msg.thumbnail_payload = Buffer.from(await decryptAttachment(thumb_buffer, msg.content.info.thumbnail_file));
}
} catch(error){
node.error(error);
msg.error = error;
node.send([null, msg]);
}
msg.filename = msg.content.filename || msg.content.body;
node.send([msg, null]);
});
}
RED.nodes.registerType("matrix-decrypt-file", MatrixDecryptFile);
function atob(a) {
return new Buffer.from(a, 'base64').toString('binary');
}
function btoa(b) {
return new Buffer.from(b).toString('base64');
}
// the following was taken & modified from https://github.com/matrix-org/browser-encrypt-attachment/blob/master/index.js
/**
* Encrypt an attachment.
* @param {ArrayBuffer} plaintextBuffer The attachment data buffer.
* @return {Promise} A promise that resolves with an object when the attachment is encrypted.
* The object has a "data" key with an ArrayBuffer of encrypted data and an "info" key
* with an object containing the info needed to decrypt the data.
*/
function encryptAttachment(plaintextBuffer) {
let cryptoKey; // The AES key object.
let exportedKey; // The AES key exported as JWK.
let ciphertextBuffer; // ArrayBuffer of encrypted data.
let sha256Buffer; // ArrayBuffer of digest.
let ivArray; // Uint8Array of AES IV
// Generate an IV where the first 8 bytes are random and the high 8 bytes
// are zero. We set the counter low bits to 0 since it makes it unlikely
// that the 64 bit counter will overflow.
ivArray = new Uint8Array(16);
crypto.getRandomValues(ivArray.subarray(0,8));
// Load the encryption key.
return crypto.subtle.generateKey(
{"name": "AES-CTR", length: 256}, true, ["encrypt", "decrypt"]
).then(function(generateKeyResult) {
cryptoKey = generateKeyResult;
// Export the Key as JWK.
return crypto.subtle.exportKey("jwk", cryptoKey);
}).then(function(exportKeyResult) {
exportedKey = exportKeyResult;
// Encrypt the input ArrayBuffer.
// Use half of the iv as the counter by setting the "length" to 64.
return crypto.subtle.encrypt(
{name: "AES-CTR", counter: ivArray, length: 64}, cryptoKey, plaintextBuffer
);
}).then(function(encryptResult) {
ciphertextBuffer = encryptResult;
// SHA-256 the encrypted data.
return crypto.subtle.digest("SHA-256", ciphertextBuffer);
}).then(function (digestResult) {
sha256Buffer = digestResult;
return {
data: ciphertextBuffer,
info: {
v: "v2",
key: exportedKey,
iv: encodeBase64(ivArray),
hashes: {
sha256: encodeBase64(new Uint8Array(sha256Buffer)),
},
},
};
});
}
/**
* Decrypt an attachment.
* @param {ArrayBuffer} ciphertextBuffer The encrypted attachment data buffer.
* @param {Object} info The information needed to decrypt the attachment.
* @param {Object} info.key AES-CTR JWK key object.
* @param {string} info.iv Base64 encoded 16 byte AES-CTR IV.
* @param {string} info.hashes.sha256 Base64 encoded SHA-256 hash of the ciphertext.
* @return {Promise} A promise that resolves with an ArrayBuffer when the attachment is decrypted.
*/
function decryptAttachment(ciphertextBuffer, info) {
if (info === undefined || info.key === undefined || info.iv === undefined
|| info.hashes === undefined || info.hashes.sha256 === undefined) {
throw new Error("Invalid info. Missing info.key, info.iv or info.hashes.sha256 key");
}
let cryptoKey; // The AES key object.
let ivArray = decodeBase64(info.iv);
let expectedSha256base64 = info.hashes.sha256;
// Load the AES from the "key" key of the info object.
return crypto.subtle.importKey(
"jwk", info.key, {"name": "AES-CTR"}, false, ["encrypt", "decrypt"]
).then(function (importKeyResult) {
cryptoKey = importKeyResult;
// Check the sha256 hash
return crypto.subtle.digest("SHA-256", ciphertextBuffer);
}).then(function (digestResult) {
if (encodeBase64(new Uint8Array(digestResult)) !== expectedSha256base64) {
throw new Error("Mismatched SHA-256 digest (expected: " + encodeBase64(new Uint8Array(digestResult)) + ") got (" + expectedSha256base64 + ")");
}
let counterLength;
if (info.v.toLowerCase() === "v1" || info.v.toLowerCase() === "v2") {
// Version 1 and 2 use a 64 bit counter.
counterLength = 64;
} else {
// Version 0 uses a 128 bit counter.
counterLength = 128;
}
return crypto.subtle.decrypt(
{name: "AES-CTR", counter: ivArray, length: counterLength}, cryptoKey, ciphertextBuffer
);
});
}
/**
* Encode a typed array of uint8 as base64.
* @param {Uint8Array} uint8Array The data to encode.
* @return {string} The base64 without padding.
*/
function encodeBase64(uint8Array) {
// Misinterpt the Uint8Array as Latin-1.
// window.btoa expects a unicode string with codepoints in the range 0-255.
// var latin1String = String.fromCharCode.apply(null, uint8Array);
// Use the builtin base64 encoder.
var paddedBase64 = btoa(uint8Array);
// Calculate the unpadded length.
var inputLength = uint8Array.length;
var outputLength = 4 * Math.floor((inputLength + 2) / 3) + (inputLength + 2) % 3 - 2;
// Return the unpadded base64.
return paddedBase64.slice(0, outputLength);
}
/**
* Decode a base64 string to a typed array of uint8.
* This will decode unpadded base64, but will also accept base64 with padding.
* @param {string} base64 The unpadded base64 to decode.
* @return {Uint8Array} The decoded data.
*/
function decodeBase64(base64) {
// Pad the base64 up to the next multiple of 4.
var paddedBase64 = base64 + "===".slice(0, (4 - base64.length % 4) % 4);
// Decode the base64 as a misinterpreted Latin-1 string.
// window.atob returns a unicode string with codepoints in the range 0-255.
var latin1String = atob(paddedBase64);
// Encode the string as a Uint8Array as Latin-1.
var uint8Array = new Uint8Array(latin1String.length);
for (var i = 0; i < latin1String.length; i++) {
uint8Array[i] = latin1String.charCodeAt(i);
}
return uint8Array;
}
}

View File

@ -0,0 +1,73 @@
<script type="text/javascript">
RED.nodes.registerType('matrix-invite-room', {
category: 'matrix',
color: '#00b7ca',
icon: "matrix.png",
outputLabels: ["success", "error"],
inputs: 1,
outputs: 2,
defaults: {
name: { value: null },
server: { value: "", type: "matrix-server-config" }
},
label: function() {
return this.name || "Room Invite";
},
paletteLabel: 'Room Invite'
});
</script>
<script type="text/html" data-template-name="matrix-invite-room">
<div class="form-row">
<label for="node-input-name"><i class="icon-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Name">
</div>
<div class="form-row">
<label for="node-input-server"><i class="fa fa-user"></i> Matrix Server Config</label>
<input type="text" id="node-input-server">
</div>
</script>
<script type="text/html" data-help-name="matrix-invite-room">
<h3>Details</h3>
<p>
This API invites a user to participate in a particular room. They do not start participating in the room until they actually join the room.
</p>
<a href="https://matrix-org.github.io/synapse/develop/admin_api/room_membership.html#edit-room-membership-api" target="_blank">Synapse API Endpoint Information</a>
<h3>Inputs</h3>
<dl class="message-properties">
<dt>msg.topic
<span class="property-type">string</span>
</dt>
<dd> The room identifier to invite to: for example, <code>!h8zld9j31:example.com. If configured on the node it overrides this input and is no longer required.</code>.</dd>
<dt>msg.userId
<span class="property-type">string</span>
</dt>
<dd> User's ID that will be invited to the room.</dd>
<dt class="optional">msg.reason
<span class="property-type">string</span>
</dt>
<dd> Reason for the membership change.</dd>
</dl>
<h3>Outputs</h3>
<ol class="node-ports">
<li>Success
<dl class="message-properties">
<dt>msg.payload <span class="property-type">object</span></dt>
<dd>Currently this endpoint returns an empty object</dd>
</dl>
</li>
<li>Error
<dl class="message-properties">
<dt>msg.error <span class="property-type">string</span></dt>
<dd>the error that occurred.</dd>
</dl>
</li>
</ol>
</script>

68
src/matrix-invite-room.js Normal file
View File

@ -0,0 +1,68 @@
module.exports = function(RED) {
function MatrixInviteRoom(n) {
RED.nodes.createNode(this, n);
let node = this;
this.name = n.name;
this.server = RED.nodes.getNode(n.server);
if(!this.server) {
node.error('Server must be configured on the node.');
return;
}
this.encodeUri = function(pathTemplate, variables) {
for (const key in variables) {
if (!variables.hasOwnProperty(key)) {
continue;
}
pathTemplate = pathTemplate.replace(
key, encodeURIComponent(variables[key]),
);
}
return pathTemplate;
};
node.status({ fill: "red", shape: "ring", text: "disconnected" });
node.server.on("disconnected", function(){
node.status({ fill: "red", shape: "ring", text: "disconnected" });
});
node.server.on("connected", function() {
node.status({ fill: "green", shape: "ring", text: "connected" });
});
node.on("input", function (msg) {
if (! node.server || ! node.server.matrixClient) {
node.error("No matrix server selected");
return;
}
if(!node.server.isConnected()) {
node.error("Matrix server connection is currently closed");
node.send([null, msg]);
}
msg.topic = node.roomId || msg.topic;
if(!msg.topic) {
node.error("msg.topic must be defined or configured on the node.");
return;
}
// we need the status code, so set onlydata to false for this request
node.server.matrixClient
.invite(msg.topic, msg.userId, undefined, msg.reason || undefined)
.then(function(e){
msg.payload = e;
node.send([msg, null]);
}).catch(function(e){
node.warn("Error creating room " + e);
msg.error = e;
node.send([null, msg]);
});
});
}
RED.nodes.registerType("matrix-invite-room", MatrixInviteRoom);
}

90
src/matrix-join-room.html Normal file
View File

@ -0,0 +1,90 @@
<script type="text/javascript">
RED.nodes.registerType('matrix-join-room',{
category: 'matrix',
color: '#00b7ca',
icon: "matrix.png",
outputLabels: ["success", "error"],
inputs:1,
outputs:2,
defaults: {
name: { value: null },
server: { value: "", type: "matrix-server-config" }
},
label: function() {
return this.name || "Join Room";
},
paletteLabel: 'Join Room'
});
</script>
<script type="text/html" data-template-name="matrix-join-room">
<div class="form-row">
<label for="node-input-name"><i class="icon-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Name">
</div>
<div class="form-row">
<label for="node-input-server"><i class="fa fa-user"></i> Matrix Server Config</label>
<input type="text" id="node-input-server">
</div>
<div class="form-tips">
Room ID must either be defined here or passed in via <code>msg.topic</code>. The config takes precedence over the input.
</div>
</script>
<script type="text/html" data-help-name="matrix-join-room">
<h3>Details</h3>
<p>This API starts a user participating in a particular room, if that user is allowed to participate in that room. After this call, the client is allowed to see all current state events in the room, and all subsequent events associated with the room until the user leaves the room.</p>
<a href="https://matrix.org/docs/spec/client_server/latest#id291" target="_blank">Matrix API Endpoint Information</a>
<h3>Inputs</h3>
<dl class="message-properties">
<dt>msg.topic
<span class="property-type">String</span>
</dt>
<dd> The room identifier or alias to join.</dd>
<dt class="optional">msg.joinOpts
<span class="property-type">Object</span>
</dt>
<dd> Extra options that can be passed when joining a room.</dd>
<dt class="optional">msg.joinOpts.syncRoom
<span class="property-type">bool</span>
</dt>
<dd> True to do a room initial sync on the resulting room. If false, the returned Room object will have no current state. Default: true.</dd>
<dt class="optional">msg.joinOpts.inviteSignUrl
<span class="property-type">bool</span>
</dt>
<dd> If the caller has a keypair 3pid invite, the signing URL is passed in this parameter.</dd>
<dt class="optional">msg.joinOpts.viaServers
<span class="property-type">[string]</span>
</dt>
<dd> The servers to attempt to join the room through. One of the servers must be participating in the room.</dd>
<dt class="optional">msg.reason
<span class="property-type">String</span>
</dt>
<dd> Reason for kicking the user.</dd>
</dl>
<h3>Outputs</h3>
<ol class="node-ports">
<li>Success
<dl class="message-properties">
<dt>msg.topic <span class="property-type">string</span></dt>
<dd>ID of the joined room.</dd>
<dt>msg.payload <span class="property-type">object</span></dt>
<dd>Room object.</dd>
</dl>
</li>
<li>Error
<dl class="message-properties">
<dt>msg.error <span class="property-type">string</span></dt>
<dd>the error that occurred.</dd>
</dl>
</li>
</ol>
</script>

61
src/matrix-join-room.js Normal file
View File

@ -0,0 +1,61 @@
module.exports = function(RED) {
function MatrixJoinRoom(n) {
RED.nodes.createNode(this, n);
var node = this;
this.name = n.name;
this.server = RED.nodes.getNode(n.server);
if (!node.server) {
node.warn("No configuration node");
return;
}
node.status({ fill: "red", shape: "ring", text: "disconnected" });
node.server.on("disconnected", function(){
node.status({ fill: "red", shape: "ring", text: "disconnected" });
});
node.server.on("connected", function() {
node.status({ fill: "green", shape: "ring", text: "connected" });
});
node.on("input", function (msg) {
if (! node.server || ! node.server.matrixClient) {
node.error("No matrix server selected");
return;
}
if(!node.server.isConnected()) {
node.error("Matrix server connection is currently closed");
node.send([null, msg]);
}
if(!msg.topic) {
node.error("Room must be specified in msg.topic");
return;
}
if(!msg.userId) {
node.error("msg.userId was not set.");
return;
}
node.server.matrixClient.joinRoom(msg.topic, msg.joinOpts || {})
.then(function(e) {
msg.payload = e;
msg.topic = e.roomId;
node.log("Successfully joined room " + msg.topic);
node.send([msg, null]);
})
.catch(function(e){
node.error("Error trying to join room " + msg.topic + ":" + e);
msg.error = e;
node.send([null, msg]);
});
});
}
RED.nodes.registerType("matrix-join-room", MatrixJoinRoom);
}

View File

@ -51,6 +51,8 @@ module.exports = function(RED) {
return;
}
msg.type = 'm.reaction';
node.server.matrixClient.sendCompleteEvent(
msg.topic,
{

View File

@ -10,10 +10,12 @@
name: { value: null },
server: { value: "", type: "matrix-server-config" },
roomId: {"value": null},
ignoreText: {"value": false},
ignoreReactions: {"value": false},
ignoreFiles: {"value": false},
ignoreImages: {"value": false},
acceptText: {"value": true},
acceptEmotes: {"value": true},
acceptStickers: {"value": true},
acceptReactions: {"value": true},
acceptFiles: {"value": true},
acceptImages: {"value": true},
},
label: function() {
return this.name || "Matrix Receive";
@ -42,41 +44,61 @@
<div class="form-row">
<input
type="checkbox"
id="node-input-ignoreText"
id="node-input-acceptText"
style="width: auto; margin-left: 125px; vertical-align: top"
/>
<label for="node-input-ignoreText" style="width: auto">
Ignore text
<label for="node-input-acceptText" style="width: auto">
Accept text <code>m.text</code>
</label>
</div>
<div class="form-row">
<input
type="checkbox"
id="node-input-acceptEmotes"
style="width: auto; margin-left: 125px; vertical-align: top"
/>
<label for="node-input-acceptEmotes" style="width: auto">
Accept emotes <code>m.emote</code>
</label>
</div>
<div class="form-row">
<input
type="checkbox"
id="node-input-acceptStickers"
style="width: auto; margin-left: 125px; vertical-align: top"
/>
<label for="node-input-acceptStickers" style="width: auto">
Accept stickers <code>m.sticker</code>
</label>
</div>
<div class="form-row">
<input
type="checkbox"
id="node-input-ignoreReactions"
id="node-input-acceptReactions"
style="width: auto; margin-left: 125px; vertical-align: top"
/>
<label for="node-input-ignoreReactions" style="width: auto">
Ignore reactions
<label for="node-input-acceptReactions" style="width: auto">
Accept reactions <code>m.reaction</code>
</label>
</div>
<div class="form-row">
<input
type="checkbox"
id="node-input-ignoreFiles"
id="node-input-acceptFiles"
style="width: auto; margin-left: 125px; vertical-align: top"
/>
<label for="node-input-ignoreFiles" style="width: auto">
Ignore files
<label for="node-input-acceptFiles" style="width: auto">
Accept files <code>m.file</code>
</label>
</div>
<div class="form-row">
<input
type="checkbox"
id="node-input-ignoreImages"
id="node-input-acceptImages"
style="width: auto; margin-left: 125px; vertical-align: top"
/>
<label for="node-input-ignoreImages" style="width: auto">
Ignore images
<label for="node-input-acceptImages" style="width: auto">
Accept images <code>m.image</code>
</label>
</div>
</script>
@ -94,6 +116,16 @@
</dd>
</dl>
<dl class="message-properties">
<dt>msg.encrypted <span class="property-type">bool</span></dt>
<dd> returns true if message was encrypted (e2ee).</dd>
</dl>
<dl class="message-properties">
<dt>msg.redacted <span class="property-type">bool</span></dt>
<dd> returns true if the message was redacted (such as deleted by the user).</dd>
</dl>
<dl class="message-properties">
<dt>msg.payload <span class="property-type">string</span></dt>
<dd>the body from the message's content.</dd>
@ -143,6 +175,24 @@
</dl>
</li>
<li><code>msg.type</code> == '<strong>m.emote</strong>'
<div class="form-tips" style="margin-bottom: 12px;">
Doesn't return anything extra
</div>
</li>
<li><code>msg.type</code> == '<strong>m.sticker</strong>'
<dl class="message-properties">
<dt>msg.url <span class="property-type">string</span></dt>
<dd>URL to the sticker image</dd>
</dl>
<dl class="message-properties">
<dt>msg.thumbnail_url <span class="property-type">string</span></dt>
<dd>URL to the thumbnail of the sticker</dd>
</dl>
</li>
<li><code>msg.type</code> == '<strong>m.file</strong>'
<dl class="message-properties">
<dt>msg.file.info <span class="property-type">string</span></dt>

View File

@ -6,10 +6,12 @@ module.exports = function(RED) {
this.name = n.name;
this.server = RED.nodes.getNode(n.server);
this.ignoreText = n.ignoreText;
this.ignoreReactions = n.ignoreReactions;
this.ignoreFiles = n.ignoreFiles;
this.ignoreImages = n.ignoreImages;
this.acceptText = n.acceptText;
this.acceptEmotes = n.acceptEmotes;
this.acceptStickers = n.acceptStickers;
this.acceptReactions = n.acceptReactions;
this.acceptFiles = n.acceptFiles;
this.acceptImages = n.acceptImages;
this.roomId = n.roomId;
this.roomIds = this.roomId ? this.roomId.split(',') : [];
@ -28,16 +30,10 @@ module.exports = function(RED) {
node.status({ fill: "green", shape: "ring", text: "connected" });
});
node.server.on("Room.timeline", function(event, room, toStartOfTimeline, data) {
node.server.on("Room.timeline", async function(event, room, toStartOfTimeline, data) {
if (toStartOfTimeline) {
return; // ignore paginated results
}
if (
event.getType() !== "m.room.message"
&& event.getType() !== "m.reaction"
) {
return; // only keep messages
}
if (!event.getSender() || event.getSender() === node.server.userId) {
return; // ignore our own messages
}
@ -50,54 +46,89 @@ module.exports = function(RED) {
return;
}
node.log("Received timeline event [" + ((event.getContent().msgtype || event.getType()) || null) + "]: (" + room.name + ") " + event.getSender() + " :: " + event.getContent().body);
try {
await node.server.matrixClient.decryptEventIfNeeded(event);
} catch (error) {
node.error(error);
return;
}
let msg = {
content: event.getContent(),
type : (event.getContent().msgtype || event.getType()) || null,
payload : event.getContent().body || null,
userId : event.getSender(),
topic : room.roomId,
eventId : event.getId(),
event : event,
encrypted : event.isEncrypted(),
redacted : event.isRedacted(),
content : event.getContent(),
type : (event.getContent()['msgtype'] || event.getType()) || null,
payload : (event.getContent()['body'] || event.getContent()) || null,
userId : event.getSender(),
topic : event.getRoomId(),
eventId : event.getId(),
event : event,
};
let knownMessageType = true;
node.log("Received" + (msg.encrypted ? ' encrypted' : '') +" timeline event [" + msg.type + "]: (" + room.name + ") " + event.getSender() + " :: " + msg.content.body);
switch(msg.type) {
case 'm.emote':
if(!node.acceptEmotes) return;
break;
case 'm.text':
if(node.ignoreText) return;
if(!node.acceptText) return;
break;
case 'm.sticker':
if(!node.acceptStickers) return;
if(msg.content.info) {
if(msg.content.info.thumbnail_url) {
msg.thumbnail_url = node.server.matrixClient.mxcUrlToHttp(msg.content.info.thumbnail_url);
}
if(msg.content.url) {
msg.url = node.server.matrixClient.mxcUrlToHttp(msg.content.url);
}
}
break;
case 'm.file':
if(!node.acceptFiles) return;
if(msg.encrypted) {
msg.url = node.server.matrixClient.mxcUrlToHttp(msg.content.file.url);
msg.mxc_url = msg.content.file.url;
} else {
msg.url = node.server.matrixClient.mxcUrlToHttp(msg.content.url);
msg.mxc_url = msg.content.url;
}
break;
case 'm.image':
if(!node.acceptImages) return;
if(msg.encrypted) {
msg.url = node.server.matrixClient.mxcUrlToHttp(msg.content.file.url);
msg.mxc_url = msg.content.file.url;
msg.thumbnail_url = node.server.matrixClient.mxcUrlToHttp(msg.content.info.thumbnail_file.url);
msg.thumbnail_mxc_url = msg.content.info.thumbnail_file.url;
} else {
msg.url = node.server.matrixClient.mxcUrlToHttp(msg.content.url);
msg.mxc_url = msg.content.url;
msg.thumbnail_url = node.server.matrixClient.mxcUrlToHttp(msg.content.info.thumbnail_url);
msg.thumbnail_mxc_url = msg.content.info.thumbnail_url;
}
break;
case 'm.reaction':
if(node.ignoreReactions) return;
if(!node.acceptReactions) return;
msg.info = msg.content["m.relates_to"].info;
msg.referenceEventId = msg.content["m.relates_to"].event_id;
msg.payload = msg.content["m.relates_to"].key;
break;
case 'm.file':
if(node.ignoreFiles) return;
msg.url = node.server.matrixClient.mxcUrlToHttp(msg.content.url);
msg.mxc_url = msg.content.url;
break;
case 'm.image':
if(node.ignoreImages) return;
msg.url = node.server.matrixClient.mxcUrlToHttp(msg.content.url);
msg.mxc_url = msg.content.url;
msg.thumbnail_url = node.server.matrixClient.mxcUrlToHttp(msg.content.info.thumbnail_url);
msg.thumbnail_mxc_url = msg.content.info.thumbnail_url;
break;
default:
knownMessageType = false;
// node.warn("Unknown event type: " + msg.type);
return;
}
if(knownMessageType) {
node.send(msg);
} else {
node.warn("Uknown message type: " + msg.type);
}
node.send(msg);
});
}
RED.nodes.registerType("matrix-receive", MatrixReceiveMessage);

View File

@ -67,7 +67,7 @@ module.exports = function(RED) {
if(msgType === 'msg.type') {
if(!msg.type) {
node.error("Message type is set to be passed in via msg.type but was not defined");
node.error("msg.type type is set to be passed in via msg.type but was not defined");
return;
}
msgType = msg.type;

View File

@ -5,7 +5,9 @@
credentials: {
userId: { type: "text", required: true },
accessToken: { type: "password", required: true },
url: { type: "text", required: true }
deviceId: { type: "text", required: true },
url: { type: "text", required: true },
enableE2ee: { type: "checkbox", value: true }
},
defaults: {
name: { value: null },
@ -23,16 +25,20 @@
<label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-config-input-name" placeholder="Name">
</div>
<div class="form-row">
<label for="node-config-input-userId"><i class="fa fa-server"></i> User ID</label>
<input type="text" placeholder="@example:matrix.org" id="node-config-input-userId">
</div>
<div class="form-row">
<label for="node-config-input-accessToken"><i class="fa fa-key"></i> Access Token</label>
<input type="text" id="node-config-input-accessToken">
</div>
<div class="form-row">
<label for="node-config-input-userId"><i class="fa fa-server"></i> User ID</label>
<input type="text" placeholder="@example:matrix.org" id="node-config-input-userId">
</div>
<div class="form-row">
<label for="node-config-input-accessToken"><i class="fa fa-key"></i> Access Token</label>
<input type="text" id="node-config-input-accessToken">
</div>
<div class="form-row">
<label for="node-config-input-deviceId"><i class="fa fa-key"></i> Device ID</label>
<input type="text" id="node-config-input-deviceId">
</div>
<div class="form-tips" style="margin-bottom: 12px;">
View the <a href="javascript:$('#red-ui-tab-help-link-button').click();">node docs</a> to figure out how to generate an Access Token.
View the <a href="javascript:$('#red-ui-tab-help-link-button').click();">node docs</a> to figure out how to generate an Access Token & Device ID. You can also generate them using the Shared Secret Registration node.
</div>
<div class="form-row">
<label for="node-config-input-url"><i class="fa fa-globe"></i> Server URL</label>
@ -48,6 +54,16 @@
Auto join invited rooms
</label>
</div>
<div class="form-row">
<input
type="checkbox"
id="node-config-input-enableE2ee"
style="width: auto; margin-left: 125px; vertical-align: top"
/>
<label for="node-config-input-enableE2ee" style="width: auto">
Enable end-to-end encryption (requires DeviceID)
</label>
</div>
</script>
<script type="text/html" data-help-name="matrix-server-config">

View File

@ -1,5 +1,10 @@
global.Olm = require('olm');
module.exports = function(RED) {
let sdk = require("matrix-js-sdk");
const sdk = require("matrix-js-sdk");
const { LocalStorage } = require('node-localstorage');
const localStorage = new LocalStorage('./matrix-local-storage');
const { LocalStorageCryptoStore } = require('matrix-js-sdk/lib/crypto/store/localStorage-crypto-store');
function MatrixServerNode(n) {
// we should add support for getting access token automatically from username/password
@ -19,8 +24,11 @@ module.exports = function(RED) {
this.connected = false;
this.name = n.name;
this.userId = this.credentials.userId;
this.deviceId = this.credentials.deviceId || null;
this.url = this.credentials.url;
this.autoAcceptRoomInvites = n.autoAcceptRoomInvites;
this.enableE2ee = this.credentials.enableE2ee || false;
this.e2ee = this.enableE2ee && this.deviceId;
if(!this.credentials.accessToken) {
node.log("Matrix connection failed: missing access token.");
@ -32,7 +40,10 @@ module.exports = function(RED) {
node.matrixClient = sdk.createClient({
baseUrl: this.url,
accessToken: this.credentials.accessToken,
userId: this.userId
sessionStore: new sdk.WebStorageSessionStore(localStorage),
cryptoStore: new LocalStorageCryptoStore(localStorage),
userId: this.userId,
deviceId: this.deviceId || undefined,
});
node.on('close', function(done) {
@ -61,12 +72,28 @@ module.exports = function(RED) {
return node.connected;
};
node.matrixClient.on("Room.timeline", function(event, room, toStartOfTimeline, data) {
node.matrixClient.on("Room.timeline", async function(event, room, toStartOfTimeline, data) {
node.emit("Room.timeline", event, room, toStartOfTimeline, data);
});
// node.matrixClient.on("RoomMember.typing", async function(event, member) {
// let isTyping = member.typing;
// let roomId = member.roomId;
// });
// node.matrixClient.on("RoomMember.powerLevel", async function(event, member) {
// let newPowerLevel = member.powerLevel;
// let newNormPowerLevel = member.powerLevelNorm;
// let roomId = member.roomId;
// });
// node.matrixClient.on("RoomMember.name", async function(event, member) {
// let newName = member.name;
// let roomId = member.roomId;
// });
// handle auto-joining rooms
node.matrixClient.on("RoomMember.membership", function(event, member) {
node.matrixClient.on("RoomMember.membership", async function(event, member) {
if (member.membership === "invite" && member.userId === node.userId) {
if(node.autoAcceptRoomInvites) {
node.matrixClient.joinRoom(member.roomId).then(function() {
@ -80,20 +107,22 @@ module.exports = function(RED) {
}
});
node.matrixClient.on("sync", function(state, prevState, data) {
node.matrixClient.on("sync", async function(state, prevState, data) {
switch (state) {
case "ERROR":
node.error("Connection to Matrix server lost");
node.setConnected(false);
break;
case "PREPARED":
case "RECONNECTING":
case "STOPPED":
node.setConnected(false);
break;
case "SYNCING":
break;
case "PREPARED":
node.setConnected(true);
break;
@ -105,8 +134,17 @@ module.exports = function(RED) {
}
});
async function run() {
if(node.e2ee){
await node.matrixClient.initCrypto();
node.matrixClient.setGlobalErrorOnUnknownDevices(false);
} else {
}
await node.matrixClient.startClient({ initialSyncLimit: 8 });
}
node.log("Connecting to Matrix server...");
node.matrixClient.startClient();
run().catch((error) => node.error(error));
}
}
@ -114,6 +152,8 @@ module.exports = function(RED) {
credentials: {
userId: { type:"text", required: true },
accessToken: { type:"text", required: true },
deviceId: { type: "text", required: true },
enableE2ee: { type: "checkbox", value: true },
url: { type: "text", required: true },
}
});

View File

@ -36,6 +36,7 @@
<script type="text/html" data-help-name="matrix-synapse-create-edit-user">
<h3>Details</h3>
<p>Add a new user to a Synapse Matrix server. This is only supported on Synapse servers and the API client must be an admin.</p>
<a href="https://matrix-org.github.io/synapse/develop/admin_api/user_admin_api.html#create-or-modify-account" target="_blank">Synapse API Endpoint Information</a>
<h3>Inputs</h3>
<dl class="message-properties">

View File

@ -49,7 +49,11 @@
<li>Reject all pending invites</li>
<li>Remove all account validity information related to the user</li>
</ul>
<div class="form-tips" style="margin-bottom: 12px;">
If you want to disable a user without doing the above use the "Synapse Add/Edit User" node to edit the user and set <code>deactivated</code> to true. Note that re-enabling the account will require you to set the password again (so the password still gets lost) but you are at least able to recover the account.
</div>
</p>
<a href="https://matrix-org.github.io/synapse/develop/admin_api/user_admin_api.html#deactivate-account" target="_blank">Synapse API Endpoint Information</a>
<h3>Inputs</h3>
<dl class="message-properties">

View File

@ -0,0 +1,77 @@
<script type="text/javascript">
RED.nodes.registerType('matrix-synapse-join-room', {
category: 'matrix',
color: '#00b7ca',
icon: "matrix.png",
outputLabels: ["success", "error"],
inputs: 1,
outputs: 2,
defaults: {
name: { value: null },
server: { value: "", type: "matrix-server-config" },
roomId: { value: null },
},
label: function() {
return this.name || "Add Room Membership";
},
paletteLabel: 'Add Room Membership'
});
</script>
<script type="text/html" data-template-name="matrix-synapse-join-room">
<div class="form-row">
<label for="node-input-name"><i class="icon-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Name">
</div>
<div class="form-row">
<label for="node-input-server"><i class="fa fa-user"></i> Matrix Server Config</label>
<input type="text" id="node-input-server">
</div>
<div class="form-row">
<label for="node-input-roomId"><i class="fa fa-user"></i> Room ID</label>
<input type="text" id="node-input-roomId" placeholder="msg.topic">
</div>
<div class="form-tips" style="margin-bottom: 12px;">
User must be an admin to use this endpoint.
</div>
</script>
<script type="text/html" data-help-name="matrix-synapse-join-room">
<h3>Details</h3>
<p>
This API allows an administrator to join an user account with a given user_id to a room with a given room_id_or_alias. You can only modify the membership of local users. The server administrator must be in the room and have permission to invite users. This only works on Synapse servers.
</p>
<a href="https://matrix-org.github.io/synapse/develop/admin_api/room_membership.html#edit-room-membership-api" target="_blank">Synapse API Endpoint Information</a>
<h3>Inputs</h3>
<dl class="message-properties">
<dt>msg.topic
<span class="property-type">string</span>
</dt>
<dd> The room identifier or alias to join: for example, <code>!h8zld9j31:example.com.</code>. Not required if set in the config and will be ignored even if set.</dd>
<dt>msg.userId
<span class="property-type">string</span>
</dt>
<dd> User's ID that will be set into the room.</dd>
</dl>
<h3>Outputs</h3>
<ol class="node-ports">
<li>Success
<dl class="message-properties">
<dt>msg.payload <span class="property-type">string</span></dt>
<dd>This returns data directly from the API endpoint. <a href="https://matrix-org.github.io/synapse/develop/admin_api/user_admin_api.html#query-current-sessions-for-a-user" target="_blank">Click here</a> to see what this returns.</dd>
</dl>
</li>
<li>Error
<dl class="message-properties">
<dt>msg.error <span class="property-type">string</span></dt>
<dd>the error that occurred.</dd>
</dl>
</li>
</ol>
</script>

View File

@ -0,0 +1,83 @@
module.exports = function(RED) {
function MatrixJoinRoom(n) {
RED.nodes.createNode(this, n);
let node = this;
this.name = n.name;
this.server = RED.nodes.getNode(n.server);
this.roomId = n.roomId;
if(!this.server) {
node.error('Server must be configured on the node.');
return;
}
this.encodeUri = function(pathTemplate, variables) {
for (const key in variables) {
if (!variables.hasOwnProperty(key)) {
continue;
}
pathTemplate = pathTemplate.replace(
key, encodeURIComponent(variables[key]),
);
}
return pathTemplate;
};
node.status({ fill: "red", shape: "ring", text: "disconnected" });
node.server.on("disconnected", function(){
node.status({ fill: "red", shape: "ring", text: "disconnected" });
});
node.server.on("connected", function() {
node.status({ fill: "green", shape: "ring", text: "connected" });
});
node.on("input", function (msg) {
if (! node.server || ! node.server.matrixClient) {
node.warn("No matrix server selected");
return;
}
if(!node.server.isConnected()) {
node.error("Matrix server connection is currently closed");
node.send([null, msg]);
}
let roomId = node.roomId || msg.topic;
if(!roomId) {
node.error("room must be defined in either msg.topic or in node config");
return;
}
if(!msg.userId) {
node.error("msg.userId is required to set user into a room");
return;
}
// we need the status code, so set onlydata to false for this request
node.server.matrixClient.http
.authedRequest(
undefined,
'POST',
node.encodeUri(
"/_synapse/admin/v1/join/$room_id_or_alias",
{ $room_id_or_alias: roomId },
),
undefined,
{ "user_id": msg.userId },
{ prefix: '' }
).then(function(e){
msg.payload = e;
node.send([msg, null]);
}).catch(function(e){
node.warn("Error joining user to room " + e);
msg.error = e;
node.send([null, msg]);
});
});
}
RED.nodes.registerType("matrix-synapse-join-room", MatrixJoinRoom);
}

View File

@ -14,9 +14,9 @@
name: { value: null },
},
label: function() {
return this.name || "Synapse Register v1";
return this.name || "Shared Secret Registration";
},
paletteLabel: 'Synapse Register v1'
paletteLabel: 'Shared Secret Registration'
});
</script>
@ -45,7 +45,8 @@
<script type="text/html" data-help-name="matrix-synapse-register">
<h3>Details</h3>
<p>Register a client with a Synapse Matrix server using the v1 admin API. This registers users with closed registration by using the <code>registration_shared_secret</code> from Synapse's <code>homeserver.yaml</code> config file. This is mainly used to generate a first time admin user on newly created Matrix servers (as you can use the V2 registration endpoint after you have an admin user).</p>
<p>Register a client with a Synapse Matrix server using the Shared Secret registration Synapse API. This registers users with closed registration by using the <code>registration_shared_secret</code> from Synapse's <code>homeserver.yaml</code> config file. This is mainly used to generate a first time admin user on newly created Matrix servers (as you can use the V2 registration endpoint after you have an admin user).</p>
<a href="https://matrix-org.github.io/synapse/develop/admin_api/register_api.html#shared-secret-registration" target="_blank">Synapse API Endpoint Information</a>
<h3>Inputs</h3>
<dl class="message-properties">

View File

@ -22,16 +22,6 @@ module.exports = function(RED) {
return;
}
node.status({ fill: "red", shape: "ring", text: "disconnected" });
node.server.on("disconnected", function(){
node.status({ fill: "red", shape: "ring", text: "disconnected" });
});
node.server.on("connected", function() {
node.status({ fill: "green", shape: "ring", text: "connected" });
});
node.on("input", function (msg) {
if(!msg.payload.username) {

View File

@ -34,6 +34,7 @@
<script type="text/html" data-help-name="matrix-synapse-users">
<h3>Details</h3>
<p>This node lists out users from a Synapse server. Only works on Synapse Matrix servers. User must be an admin to call this API.</p>
<a href="https://matrix-org.github.io/synapse/develop/admin_api/user_admin_api.html#list-accounts" target="_blank">Synapse API Endpoint Information</a>
<h3>Inputs</h3>
<dl class="message-properties">
@ -101,9 +102,4 @@
</dl>
</li>
</ol>
<h3>References</h3>
<ul>
<li><a href="https://matrix-org.github.io/synapse/develop/admin_api/user_admin_api.html#list-accounts">Matrix Docs</a> - List Accounts API method information</li>
</ul>
</script>