diff --git a/package.json b/package.json index 36ba60a..5c4095d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "node-red-contrib-matrix-chat", - "version": "0.9.0", + "version": "0.9.1", "description": "Matrix chat server client for Node-RED", "dependencies": { "abort-controller": "^3.0.0", diff --git a/src/matrix-event-relations.js b/src/matrix-event-relations.js index 9e37030..191f319 100644 --- a/src/matrix-event-relations.js +++ b/src/matrix-event-relations.js @@ -1,4 +1,4 @@ -const {RelationType, EventType, Direction} = require("matrix-js-sdk"); +const sdkPromise = import("matrix-js-sdk"); module.exports = function(RED) { function MatrixFetchRelations(n) { @@ -49,14 +49,17 @@ module.exports = function(RED) { } try { + const sdk = await sdkPromise; + const Direction = sdk.Direction; + function evaluateNodePropertySafe(value, type, node, msg) { try { return RED.util.evaluateNodeProperty(value, type, node, msg); } catch (e) { if (e instanceof TypeError) { - return undefined; // Handle TypeError and return undefined + return undefined; } - throw e; // Re-throw other errors to prevent masking issues + throw e; } } @@ -71,16 +74,16 @@ module.exports = function(RED) { to = evaluateNodePropertySafe(node.toValue, node.toType, node, msg); let opts = { dir: direction }; - if(limit) { + if (limit) { opts.limit = limit; } - if(recurse === true || recurse === false) { + if (recurse === true || recurse === false) { opts.recurse = recurse; } - if(from) { + if (from) { opts.from = from; } - if(to) { + if (to) { opts.to = to; } diff --git a/src/matrix-mark-read.js b/src/matrix-mark-read.js index bc8eebe..5d8c62b 100644 --- a/src/matrix-mark-read.js +++ b/src/matrix-mark-read.js @@ -1,9 +1,8 @@ -const {TimelineWindow, RelationType, Filter} = require("matrix-js-sdk"); const crypto = require('crypto'); -module.exports = function(RED) { - function MatrixReceiveMessage(n) { - RED.nodes.createNode(this, n); +module.exports = function(RED) { + function MatrixMarkRead(n) { + RED.nodes.createNode(this, n); let node = this; this.name = n.name; this.server = RED.nodes.getNode(n.server); @@ -29,7 +28,7 @@ module.exports = function(RED) { }); node.on("input", async function (msg) { - if (! node.server || ! node.server.matrixClient) { + if (!node.server || !node.server.matrixClient) { node.error("No matrix server selected", msg); return; } @@ -38,15 +37,15 @@ module.exports = function(RED) { let value = property; if (type === "msg") { value = RED.util.getMessageProperty(msg, property); - } else if ((type === 'flow') || (type === 'global')) { + } else if (type === 'flow' || type === 'global') { try { value = RED.util.evaluateNodeProperty(property, type, node, msg); - } catch(e2) { + } catch (e2) { throw new Error("Invalid value evaluation"); } - } else if(type === "bool") { + } else if (type === "bool") { value = (property === 'true'); - } else if(type === "num") { + } else if (type === "num") { value = Number(property); } return value; @@ -66,9 +65,9 @@ module.exports = function(RED) { throw new Error(`Event ${eventId} not found in room ${roomId}.`); } - await node.server.matrixClient.sendReceipt(event, "m.read") + await node.server.matrixClient.sendReceipt(event, "m.read"); node.send([msg, null]); - } catch(e) { + } catch (e) { msg.error = `Room pagination error: ${e}`; node.error(msg.error, msg); node.send([null, msg]); @@ -79,5 +78,5 @@ module.exports = function(RED) { node.server.deregister(node); }); } - RED.nodes.registerType("matrix-mark-read", MatrixReceiveMessage); -} \ No newline at end of file + RED.nodes.registerType("matrix-mark-read", MatrixMarkRead); +} diff --git a/src/matrix-paginate-room.js b/src/matrix-paginate-room.js index 7fbab8d..ddaf491 100644 --- a/src/matrix-paginate-room.js +++ b/src/matrix-paginate-room.js @@ -1,7 +1,8 @@ -const {TimelineWindow, RelationType, Filter} = require("matrix-js-sdk"); +const sdkPromise = import("matrix-js-sdk"); const crypto = require('crypto'); + module.exports = function(RED) { - function MatrixReceiveMessage(n) { + function MatrixPaginateRoom(n) { RED.nodes.createNode(this, n); let node = this; @@ -34,7 +35,7 @@ module.exports = function(RED) { }); node.on("input", async function (msg) { - if (! node.server || ! node.server.matrixClient) { + if (!node.server || !node.server.matrixClient) { node.error("No matrix server selected", msg); return; } @@ -43,43 +44,47 @@ module.exports = function(RED) { let value = property; if (type === "msg") { value = RED.util.getMessageProperty(msg, property); - } else if ((type === 'flow') || (type === 'global')) { + } else if (type === 'flow' || type === 'global') { try { value = RED.util.evaluateNodeProperty(property, type, node, msg); } catch(e2) { throw new Error("Invalid value evaluation"); } - } else if(type === "bool") { + } else if (type === "bool") { value = (property === 'true'); - } else if(type === "num") { + } else if (type === "num") { value = Number(property); } return value; } function setToValue(value, type, property) { - if(type === 'global' || type === 'flow') { + if (type === 'global' || type === 'flow') { var contextKey = RED.util.parseContextStore(property); if (/\[msg/.test(contextKey.key)) { - // The key has a nest msg. reference to evaluate first - contextKey.key = RED.util.normalisePropertyExpression(contextKey.key, msg, true) + // The key has a nested msg. reference that must be evaluated first + contextKey.key = RED.util.normalisePropertyExpression(contextKey.key, msg, true); } var target = node.context()[type]; - var callback = err => { + target.set(contextKey.key, value, contextKey.store, err => { if (err) { node.error(err, msg); - getterErrors[rule.p] = err.message; } - } - target.set(contextKey.key, value, contextKey.store, callback); - } else if(type === 'msg') { + }); + } else if (type === 'msg') { if (!RED.util.setMessageProperty(msg, property, value)) { - node.warn(RED._("change.errors.no-override",{property:property})); + node.warn(RED._("change.errors.no-override", { property: property })); } } } try { + // Dynamically load the SDK + const sdk = await sdkPromise; + const TimelineWindow = sdk.TimelineWindow; + const RelationType = sdk.RelationType; + // (Filter was imported originally but is not used, so we omit it.) + let roomId = getToValue(msg, node.roomType, node.roomValue), paginateBackwards = getToValue(msg, node.paginateBackwardsType, node.paginateBackwardsValue), pageSize = getToValue(msg, node.pageSizeType, node.pageSizeValue), @@ -87,42 +92,37 @@ module.exports = function(RED) { let room = node.server.matrixClient.getRoom(roomId); - if(!room) { + if (!room) { throw new Error(`Room ${roomId} does not exist`); } - if(pageSize > node.server.initialSyncLimit) { + if (pageSize > node.server.initialSyncLimit) { throw new Error(`Page size=${pageSize} cannot exceed initialSyncLimit=${node.server.initialSyncLimit}`); } - if(!pageKey) { + if (!pageKey) { pageKey = crypto.randomUUID(); setToValue(pageKey, node.paginateKeyType, node.paginateKeyValue); } let timelineWindow = node.timelineWindows.get(pageKey), moreMessages = true; - if(!timelineWindow) { + if (!timelineWindow) { let timelineSet = room.getUnfilteredTimelineSet(); - // node.debug(JSON.stringify(timelineSet.getFilter())); - - // MatrixClient's option initialSyncLimit gets set to the filter we are using - // so override that value with our pageSize + // MatrixClient's option initialSyncLimit gets set to the filter we are using, + // so override that value with our pageSize. timelineWindow = new TimelineWindow(node.server.matrixClient, timelineSet); await timelineWindow.load(msg.eventId || null, pageSize); node.timelineWindows.set(pageKey, timelineWindow); } else { - moreMessages = await timelineWindow.paginate(paginateBackwards ? 'b' : 'f', pageSize); // b for backwards f for forwards - if(moreMessages) { + moreMessages = await timelineWindow.paginate(paginateBackwards ? 'b' : 'f', pageSize); // 'b' for backwards, 'f' for forwards + if (moreMessages) { await timelineWindow.unpaginate(pageSize, !paginateBackwards); } } - // MatrixEvent objects are massive so this throws an encode error for the string being too long - // since msg objects convert to JSON - // msg.payload = moreMessages ? timelineWindow.getEvents() : false; - + // To avoid errors converting massive MatrixEvent objects to JSON, we omit them. msg.payload = false; msg.start = timelineWindow.getTimelineIndex('b')?.index; msg.end = timelineWindow.getTimelineIndex('f')?.index; - if(moreMessages) { + if (moreMessages) { msg.payload = timelineWindow.getEvents().map(function(event) { return { encrypted : event.isEncrypted(), @@ -152,5 +152,5 @@ module.exports = function(RED) { node.server.deregister(node); }); } - RED.nodes.registerType("matrix-paginate-room", MatrixReceiveMessage); -} \ No newline at end of file + RED.nodes.registerType("matrix-paginate-room", MatrixPaginateRoom); +} diff --git a/src/matrix-receive.js b/src/matrix-receive.js index e43abef..77059c8 100644 --- a/src/matrix-receive.js +++ b/src/matrix-receive.js @@ -1,4 +1,3 @@ -const {RelationType} = require("matrix-js-sdk"); module.exports = function(RED) { function MatrixReceiveMessage(n) { RED.nodes.createNode(this, n); diff --git a/src/matrix-send-message.js b/src/matrix-send-message.js index 70549e6..c954ca1 100644 --- a/src/matrix-send-message.js +++ b/src/matrix-send-message.js @@ -1,9 +1,8 @@ -const {RelationType} = require("matrix-js-sdk"); +const sdkPromise = import("matrix-js-sdk"); module.exports = function(RED) { function MatrixSendImage(n) { RED.nodes.createNode(this, n); - var node = this; this.name = n.name; @@ -68,12 +67,17 @@ module.exports = function(RED) { node.status({ fill: "green", shape: "ring", text: "connected" }); }); - node.on("input", function (msg) { + // Make the input handler async so we can await the dynamic import. + node.on("input", async function (msg) { + // Await the SDK import and get the RelationType constant. + const sdk = await sdkPromise; + const RelationType = sdk.RelationType; + function getToValue(msg, type, property) { let value = property; if (type === "msg") { value = RED.util.getMessageProperty(msg, property); - } else if ((type === 'flow') || (type === 'global')) { + } else if (type === 'flow' || type === 'global') { try { value = RED.util.evaluateNodeProperty(property, type, node, msg); } catch(e2) { @@ -115,19 +119,19 @@ module.exports = function(RED) { } let content = null; - if(typeof payload === 'object') { + if (typeof payload === 'object') { content = payload; } else { - if(msgType === 'msg.type') { - if(!msg.type) { - node.error("msg.type type is set to be passed in via msg.type but was not defined", msg); + if (msgType === 'msg.type') { + if (!msg.type) { + node.error("msg.type is set to be passed in via msg.type but was not defined", msg); return; } msgType = msg.type; } - if(msgFormat === 'msg.format') { - if(!Object.hasOwn(msg, 'format')) { + if (msgFormat === 'msg.format') { + if (!Object.hasOwn(msg, 'format')) { node.error("Message format is set to be passed in via msg.format but was not defined", msg); return; } @@ -139,7 +143,7 @@ module.exports = function(RED) { body: payload.toString() }; - if(msgFormat === 'html') { + if (msgFormat === 'html') { content.format = "org.matrix.custom.html"; content.formatted_body = (typeof msg.formatted_payload !== 'undefined' && msg.formatted_payload) @@ -147,15 +151,15 @@ module.exports = function(RED) { : payload.toString(); } - if((node.replaceMessage || msg.replace) && msg.eventId) { + if ((node.replaceMessage || msg.replace) && msg.eventId) { content['m.new_content'] = { msgtype: content.msgtype, body: content.body }; - if('format' in content) { + if ('format' in content) { content['m.new_content']['format'] = content['format']; } - if('formatted_body' in content) { + if ('formatted_body' in content) { content['m.new_content']['formatted_body'] = content['formatted_body']; } @@ -164,17 +168,19 @@ module.exports = function(RED) { event_id: msg.eventId }; content['body'] = ' * ' + content['body']; - } else if(threadReply) { - // if incoming message is a reply to a thread we fetch the thread parent from the m.relates_to property - // otherwise fallback to msg.eventId - let threadParent = (msg?.content?.['m.relates_to']?.rel_type === RelationType.Thread ? msg?.content?.['m.relates_to']?.event_id : null) || msg.eventId; - if(threadParent) { + } else if (threadReply) { + // If incoming message is a reply to a thread we fetch the thread parent from m.relates_to, + // otherwise fallback to msg.eventId. + let threadParent = (msg?.content?.['m.relates_to']?.rel_type === RelationType.Thread + ? msg?.content?.['m.relates_to']?.event_id + : null) || msg.eventId; + if (threadParent) { content["m.relates_to"] = { "rel_type": RelationType.Thread, "event_id": threadParent, "is_falling_back": true, }; - if(msg.eventId !== threadParent) { + if (msg.eventId !== threadParent) { content["m.relates_to"]["m.in_reply_to"] = { "event_id": msg.eventId }; @@ -202,4 +208,4 @@ module.exports = function(RED) { }); } RED.nodes.registerType("matrix-send-message", MatrixSendImage); -} +}; diff --git a/src/matrix-server-config.js b/src/matrix-server-config.js index 5f77995..753b8a0 100644 --- a/src/matrix-server-config.js +++ b/src/matrix-server-config.js @@ -1,32 +1,52 @@ -const {RelationType, TimelineWindow} = require("matrix-js-sdk"); - global.Olm = require('olm'); const fs = require("fs-extra"); -const sdk = require("matrix-js-sdk"); +let RelationType, TimelineWindow, sdk, LocalStorageCryptoStore, RoomEvent, RoomMemberEvent, HttpApiEvent, ClientEvent, MemoryStore; + +(async () => { + const mod = await import("matrix-js-sdk"); + RelationType = mod.RelationType; + TimelineWindow = mod.TimelineWindow; + // matrix-js-sdk doesn't export a default – the top-level export is the same object: + sdk = mod; + + RoomEvent = mod.RoomEvent; + RoomMemberEvent = mod.RoomMemberEvent; + HttpApiEvent = mod.HttpApiEvent; + ClientEvent = mod.ClientEvent; + MemoryStore = mod.MemoryStore; + + // For LocalStorageCryptoStore, specify the file extension for Node 20+: + const cmod = await import("matrix-js-sdk/lib/crypto/store/localStorage-crypto-store.js"); + LocalStorageCryptoStore = cmod.LocalStorageCryptoStore; +})(); + const { resolve } = require('path'); const { LocalStorage } = require('node-localstorage'); -const { LocalStorageCryptoStore } = require('matrix-js-sdk/lib/crypto/store/localStorage-crypto-store'); -const {RoomEvent, RoomMemberEvent, HttpApiEvent, ClientEvent, MemoryStore} = require("matrix-js-sdk"); require("abort-controller/polyfill"); // polyfill abort-controller if we don't have it if (!globalThis.fetch) { // polyfill fetch if we don't have it if (!globalThis.fetch) { import('node-fetch').then(({ default: fetch, Headers, Request, Response }) => { - Object.assign(globalThis, { fetch, Headers, Request, Response }) - }) + Object.assign(globalThis, { fetch, Headers, Request, Response }); + }); } } module.exports = function(RED) { // disable logging if set to "off" let loggingSettings = RED.settings.get('logging'); - if( + if ( typeof loggingSettings.console !== 'undefined' && typeof loggingSettings.console.level !== 'undefined' && ['info','debug','trace'].indexOf(loggingSettings.console.level.toLowerCase()) >= 0 ) { - const { logger } = require('matrix-js-sdk/lib/logger'); - logger.disableAll(); + import('matrix-js-sdk/lib/logger.js') + .then(({ logger }) => { + logger.disableAll(); + }) + .catch((err) => { + console.error("Error loading logger module:", err); + }); } function MatrixFolderNameFromUserId(name) { @@ -59,14 +79,14 @@ module.exports = function(RED) { this.initializedAt = new Date(); node.initialSyncLimit = 25; - // Keep track of all consumers of this node to be able to catch errors + // Keep track of all consumers of this node to catch errors node.register = function(consumerNode) { node.users[consumerNode.id] = consumerNode; }; node.deregister = function(consumerNode) { delete node.users[consumerNode.id]; }; - + if(!this.userId) { node.log("Matrix connection failed: missing user ID in configuration."); return; @@ -99,7 +119,7 @@ module.exports = function(RED) { device_id = this.matrixClient.getDeviceId(); if(!device_id && node.enableE2ee) { - node.error("Failed to auto detect deviceId for this auth token. You will need to manually specify one. You may need to login to create a new deviceId.", {}) + node.error("Failed to auto detect deviceId for this auth token. You will need to manually specify one. You may need to login to create a new deviceId.", {}); } else { if(!stored_device_id || stored_device_id !== device_id) { node.log(`Saving Device ID (old:${stored_device_id} new:${device_id})`); @@ -116,7 +136,7 @@ module.exports = function(RED) { node.matrixClient.setDeviceDetails(device_id, { display_name: node.deviceLabel }).then( - function(response) {}, + function() {}, function(error) { node.error("Failed to set device label: " + error, {}); } @@ -154,7 +174,6 @@ module.exports = function(RED) { }), userId: this.userId, deviceId: (this.deviceId || getStoredDeviceId(localStorage)) || undefined - // verificationMethods: ["m.sas.v1"] }); node.debug(`hasLazyLoadMembersEnabled=${node.matrixClient.hasLazyLoadMembersEnabled()}`); @@ -180,7 +199,7 @@ module.exports = function(RED) { if(node.globalAccess) { try { node.context().global.set('matrixClient["'+node.userId+'"]', undefined); - } catch(e){ + } catch(e) { node.error(e.message, {}); } } @@ -194,15 +213,15 @@ module.exports = function(RED) { node.matrixClient.on(RoomEvent.Timeline, async function(event, room, toStartOfTimeline, removed, data) { if (toStartOfTimeline) { node.log("Ignoring" + (event.isEncrypted() ? ' encrypted' : '') +" timeline event [" + (event.getContent()['msgtype'] || event.getType()) + "]: (" + room.name + ") " + event.getId() + " for reason: paginated result"); - return; // ignore paginated results + return; } if (!data || !data.liveEvent) { node.log("Ignoring" + (event.isEncrypted() ? ' encrypted' : '') +" timeline event [" + (event.getContent()['msgtype'] || event.getType()) + "]: (" + room.name + ") " + event.getId() + " for reason: old message"); - return; // ignore old message (we only want live events) + return; } if(node.initializedAt > event.getDate()) { node.log("Ignoring" + (event.isEncrypted() ? ' encrypted' : '') +" timeline event [" + (event.getContent()['msgtype'] || event.getType()) + "]: (" + room.name + ") " + event.getId() + " for reason: old message before init"); - return; // skip events that occurred before our client initialized + return; } try { @@ -377,7 +396,6 @@ module.exports = function(RED) { } }); - node.matrixClient.on(HttpApiEvent.SessionLoggedOut, async function(errorObj){ // Example if user auth token incorrect: // { @@ -450,7 +468,7 @@ module.exports = function(RED) { node.error("Auth check failed: " + err, {}); } } - ) + ); })(); } } @@ -475,19 +493,21 @@ module.exports = function(RED) { deviceId = req.body.deviceId || undefined, displayName = req.body.displayName || undefined; - const matrixClient = sdk.createClient({ - baseUrl: baseUrl, - deviceId: deviceId, - timelineSupport: true, - localTimeoutMs: '30000' - }); + (async () => { + const mod = await import("matrix-js-sdk"); + const matrixClient = mod.createClient({ + baseUrl: baseUrl, + deviceId: deviceId, + timelineSupport: true, + localTimeoutMs: '30000' + }); - new TimelineWindow() + new TimelineWindow(); // from our top-level variable, but to keep minimal changes, + // you can just do: (await import("matrix-js-sdk")).TimelineWindow(); - matrixClient.timelineSupport = true; + matrixClient.timelineSupport = true; - matrixClient.login( - 'm.login.password', { + matrixClient.login('m.login.password', { identifier: { type: 'm.id.user', user: userId, @@ -495,23 +515,27 @@ module.exports = function(RED) { password: password, initial_device_display_name: displayName }) - .then( - function(response) { - res.json({ - 'result': 'ok', - 'token': response.access_token, - 'device_id': response.device_id, - 'user_id': response.user_id, - }); - }, - function(err) { - res.json({ - 'result': 'error', - 'message': err - }); - } - ); - }); + .then( + function(response) { + res.json({ + 'result': 'ok', + 'token': response.access_token, + 'device_id': response.device_id, + 'user_id': response.user_id, + }); + }, + function(err) { + res.json({ + 'result': 'error', + 'message': err + }); + } + ); + })().catch(err => { + res.json({ result: 'error', message: err.toString() }); + }); + } + ); function upgradeDirectoryIfNecessary(node, storageDir) { let oldStorageDir = './matrix-local-storage', @@ -552,9 +576,6 @@ module.exports = function(RED) { } } - /** - * If a device ID is stored we will use that for the client - */ function getStoredDeviceId(localStorage) { let deviceId = localStorage.getItem('my_device_id'); if(deviceId === "null" || !deviceId) { @@ -570,4 +591,4 @@ module.exports = function(RED) { localStorage.setItem('my_device_id', deviceId); return true; } -} +}; \ No newline at end of file