Compare commits

...

6 Commits

Author SHA1 Message Date
skylord123 bc97c564d3 Fix libolm->rust crypto migration crash on localStorage stores
matrix-js-sdk's LocalStorageCryptoStore.getEndToEndSessionsBatch()
returns olm sessions without the deviceKey/sessionId fields, since
those live only in the storage key path. initRustCrypto()'s migration
then assigns the undefined deviceKey to PickledSession.senderKey and
crashes in the WASM setter with "Cannot read properties of undefined
(reading 'length')". The IndexedDB backend embeds those fields in the
record and is unaffected, which is why this only hit installs that
were started on the legacy localStorage crypto store.

Patch the instance to re-derive deviceKey/sessionId from the
crypto.sessions/<deviceKey> keys before returning a batch, so both
the migration and its delete-after-migrate step work.
2026-05-24 14:17:30 -06:00
skylord123 5012c603aa Add Send Location example flow 2026-05-23 15:46:09 -06:00
skylord123 af1067a99b Refresh README features and tagline for v1.0.0
Tagline now mentions E2EE; the beta banner is replaced with a permanent
issues/release-notes pointer. Features list gains Session management and
Send Location, expands Receive events to all 10 handled msgtypes, and
notes Markdown alongside HTML in Send & modify messages.
2026-05-23 13:51:49 -06:00
skylord123 390a5b264e Add Send Location node; expand Receive's m.location output
New matrix-send-location node publishes m.location events using
matrix-js-sdk's makeLocationContent helper, so the wire format matches
Element's "Share my location". Per-message inputs are typed inputs
(msg / flow / global / num / str), defaulting to the obvious msg.*
names.

matrix-receive's m.location handler now also sets msg.latitude,
msg.longitude, msg.altitude, msg.description, msg.assetType, and
msg.timestamp on the output (additive - msg.geo_uri / msg.payload are
unchanged), so a Receive node wired straight into Send Location resends
a byte-equivalent event.

Help docs in both nodes updated.
2026-05-23 12:36:48 -06:00
skylord123 9dc4362819 Add native Markdown format option to the Send Message node 2026-05-22 22:36:11 -06:00
skylord123 1801b49fae Set filename on the matrix-upload-file content payload 2026-05-22 19:24:19 -06:00
16 changed files with 1235 additions and 26 deletions
+12 -10
View File
@@ -1,7 +1,7 @@
# node-red-contrib-matrix-chat
[Matrix](https://matrix.org/) chat server client for [Node-RED](https://nodered.org/)
[Matrix](https://matrix.org/) chat client for [Node-RED](https://nodered.org/), with full end-to-end encryption support.
***Currently in beta. Please report any issues in our repository to help us reach a stable, well-tested release. Breaking changes may occur before our first stable release, so be sure to check the changelog before updating.***
Please report any issues in our [issue tracker](https://github.com/Skylar-Tech/node-red-contrib-matrix-chat/issues). Breaking changes between releases are listed in the [release notes](https://github.com/Skylar-Tech/node-red-contrib-matrix-chat/releases); check them before upgrading.
Join our public Matrix room for help: [#node-red-contrib-matrix-chat:skylar.tech](https://app.element.io/#/room/#node-red-contrib-matrix-chat:skylar.tech)
@@ -11,14 +11,16 @@ Join our public Matrix room for help: [#node-red-contrib-matrix-chat:skylar.tech
Supported functionality in this package includes:
- **End-to-end encryption (E2EE)** send and receive encrypted messages (see the [encryption notes](#end-to-end-encryption-notes))
- **Cross-signing & secure backup** interactive setup from the server config node so the bot's own device shows as verified
- **Device verification** interactive SAS (emoji) verification, either from the server config node or with the `matrix-verification` flow nodes
- **Receive events** from rooms: Messages, reactions, images, audio, locations, files, encrypted or unencrypted
- **End-to-end encryption (E2EE)**: send and receive encrypted messages (see the [encryption notes](#end-to-end-encryption-notes))
- **Cross-signing & secure backup**: interactive setup from the server config node so the bot's own device shows as verified
- **Device verification**: interactive SAS (emoji) verification, either from the server config node or with the `matrix-verification` flow nodes
- **Session management**: review, verify, rename, or remove the account's sessions from the server config node (Element-style)
- **Receive events** from rooms (encrypted or unencrypted): messages, reactions, emotes, notices, stickers, images, video, audio, locations, files
- **Fetch/modify room state**: Update room settings
- **Paginate room history**
- **Send files** to rooms, encrypted or unencrypted
- **Send/edit messages** (supports plain text and HTML formats)
- **Send & modify messages** (plain text, Markdown, and HTML formats)
- **Send location messages**: produce `m.location` events that match Element's *"Share my location"* wire format
- **Send typing notifications**
- **Delete events** (messages, reactions, etc.)
- **Decrypt files** in E2EE rooms
@@ -54,9 +56,9 @@ You're not limited to just the nodes we've created. Enable global access in your
### End-to-End Encryption Notes
- E2EE uses the Rust crypto stack from `matrix-js-sdk`. The first time a bot starts after upgrading from an older version, any existing (legacy libolm) crypto state is migrated automatically.
- **Storage:** E2EE state is saved in a folder called `matrix-client-storage` within your Node-RED directory — each account's Rust crypto store is persisted there as `rust-crypto-store.v8` (snapshotted on shutdown and every 5 minutes). Setting up secure backup (below) lets you recover the account's keys even if this folder is lost.
- **Cross-signing & secure backup strongly recommended:** open the server config node and use the **Set up secure backup & cross-signing** button. It lets you unlock an existing secure backup with its recovery key, or create a fresh one; once done, the bot's own device is cross-signed and shows as verified to others. **Save the recovery key somewhere safe** — it is shown only once, and is the only way to restore the account's encryption keys if the crypto store is ever lost.
- **Device verification:** there are two ways to verify devices
- **Storage:** E2EE state is saved in a folder called `matrix-client-storage` within your Node-RED directory. Each account's Rust crypto store is persisted there as `rust-crypto-store.v8` (snapshotted on shutdown and every 5 minutes). Setting up secure backup (below) lets you recover the account's keys even if this folder is lost.
- **Cross-signing & secure backup (strongly recommended):** open the server config node and use the **Set up secure backup & cross-signing** button. It lets you unlock an existing secure backup with its recovery key, or create a fresh one; once done, the bot's own device is cross-signed and shows as verified to others. **Save the recovery key somewhere safe.** It is shown only once, and is the only way to restore the account's encryption keys if the crypto store is ever lost.
- **Device verification:** there are two ways to verify devices:
- From the server config node, the **Pending verification requests** button opens a list of incoming requests and lets you complete the SAS (emoji) check interactively, no flow required.
- Or build your own flow: the `matrix-verification` node emits verification requests and phase changes, and `matrix-verification-action` accepts, starts, confirms, or cancels them (e.g. emailing the SAS emoji for a human to confirm). See the [device verification example](https://github.com/Skylar-Tech/node-red-contrib-matrix-chat/tree/master/examples#device-verification).
+13
View File
@@ -183,6 +183,19 @@ Any messages containing "delete" will be removed by the client.
</details>
<details>
<summary>Send a location to a room</summary>
[View JSON](send-location-to-room.json)
Sends an `m.location` event (a map pin) for the country of Norway to the configured room. Element and other matrix-react-sdk clients render it as a pin on the map with the label *"Norway"*; clients without map rendering see an auto-generated text fallback.
Update the `Send Location` node's Room ID to your own room before deploying. The inject node is configured to fire a bare message (no payload, no topic), so the Send Location node falls back to its configured values for everything: asset type `m.pin`, description `Norway`, and geo URI `geo:60.4720,8.4689`.
![send-location-to-room.png](send-location-to-room.png)
</details>
### Event Handling
<details>
+69
View File
@@ -0,0 +1,69 @@
[
{
"id": "222bb5ef43d621b4",
"type": "group",
"z": "f025a8b9fbd1b054",
"name": "Send location to room",
"style": {
"label": true
},
"nodes": [
"ee742dfa934b4892",
"1ef540382789ff9d"
],
"x": 354,
"y": 5279,
"w": 392,
"h": 82
},
{
"id": "ee742dfa934b4892",
"type": "matrix-send-location",
"z": "f025a8b9fbd1b054",
"g": "222bb5ef43d621b4",
"name": "",
"server": "",
"roomId": "!example:test.org",
"latitudeType": "msg",
"latitudeValue": "latitude",
"longitudeType": "msg",
"longitudeValue": "longitude",
"altitudeType": "msg",
"altitudeValue": "altitude",
"geoUriType": "str",
"geoUriValue": "geo:60.4720,8.4689",
"descriptionType": "str",
"descriptionValue": "Norway",
"assetTypeType": "str",
"assetTypeValue": "m.pin",
"timestampType": "msg",
"timestampValue": "timestamp",
"textType": "msg",
"textValue": "payload",
"x": 640,
"y": 5320,
"wires": [
[],
[]
]
},
{
"id": "1ef540382789ff9d",
"type": "inject",
"z": "f025a8b9fbd1b054",
"g": "222bb5ef43d621b4",
"name": "",
"props": [],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"x": 460,
"y": 5320,
"wires": [
[
"ee742dfa934b4892"
]
]
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

+81 -5
View File
@@ -10,12 +10,15 @@
"license": "SEE LICENSE FILE",
"dependencies": {
"abort-controller": "^3.0.0",
"commonmark": "^0.31.2",
"fake-indexeddb": "^6.2.5",
"fluent-ffmpeg": "^2.1.2",
"fs-extra": "^11.1.0",
"got": "^12.0.2",
"image-size": "^1.0.2",
"isomorphic-webcrypto": "^2.3.8",
"linkifyjs": "^4.3.3",
"lodash.escape": "^4.0.1",
"matrix-js-sdk": "^41.5.0",
"mime": "^3.0.0",
"node-fetch": "^3.3.0",
@@ -7314,6 +7317,23 @@
"optional": true,
"peer": true
},
"node_modules/commonmark": {
"version": "0.31.2",
"resolved": "https://registry.npmjs.org/commonmark/-/commonmark-0.31.2.tgz",
"integrity": "sha512-2fRLTyb9r/2835k5cwcAwOj0DEc44FARnMp5veGsJ+mEAZdi52sNopLu07ZyElQUz058H43whzlERDIaaSw4rg==",
"license": "BSD-2-Clause",
"dependencies": {
"entities": "~3.0.1",
"mdurl": "~1.0.1",
"minimist": "~1.2.8"
},
"bin": {
"commonmark": "bin/commonmark"
},
"engines": {
"node": "*"
}
},
"node_modules/compare-versions": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz",
@@ -7880,6 +7900,18 @@
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz",
"integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-editor": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
@@ -10350,6 +10382,12 @@
"optional": true,
"peer": true
},
"node_modules/linkifyjs": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
"license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -10379,6 +10417,12 @@
"optional": true,
"peer": true
},
"node_modules/lodash.escape": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz",
"integrity": "sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw==",
"license": "MIT"
},
"node_modules/lodash.throttle": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
@@ -10794,6 +10838,12 @@
"optional": true,
"peer": true
},
"node_modules/mdurl": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
"integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==",
"license": "MIT"
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -11836,8 +11886,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"optional": true,
"peer": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@@ -20839,6 +20887,16 @@
"optional": true,
"peer": true
},
"commonmark": {
"version": "0.31.2",
"resolved": "https://registry.npmjs.org/commonmark/-/commonmark-0.31.2.tgz",
"integrity": "sha512-2fRLTyb9r/2835k5cwcAwOj0DEc44FARnMp5veGsJ+mEAZdi52sNopLu07ZyElQUz058H43whzlERDIaaSw4rg==",
"requires": {
"entities": "~3.0.1",
"mdurl": "~1.0.1",
"minimist": "~1.2.8"
}
},
"compare-versions": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz",
@@ -21287,6 +21345,11 @@
"once": "^1.4.0"
}
},
"entities": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz",
"integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q=="
},
"env-editor": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
@@ -23163,6 +23226,11 @@
"optional": true,
"peer": true
},
"linkifyjs": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg=="
},
"locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -23186,6 +23254,11 @@
"optional": true,
"peer": true
},
"lodash.escape": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz",
"integrity": "sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw=="
},
"lodash.throttle": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
@@ -23524,6 +23597,11 @@
"optional": true,
"peer": true
},
"mdurl": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
"integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g=="
},
"media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -24351,9 +24429,7 @@
"minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"optional": true,
"peer": true
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="
},
"minipass": {
"version": "3.1.6",
+5 -1
View File
@@ -4,12 +4,15 @@
"description": "Matrix chat server client for Node-RED",
"dependencies": {
"abort-controller": "^3.0.0",
"commonmark": "^0.31.2",
"fake-indexeddb": "^6.2.5",
"fluent-ffmpeg": "^2.1.2",
"fs-extra": "^11.1.0",
"got": "^12.0.2",
"image-size": "^1.0.2",
"isomorphic-webcrypto": "^2.3.8",
"linkifyjs": "^4.3.3",
"lodash.escape": "^4.0.1",
"matrix-js-sdk": "^41.5.0",
"mime": "^3.0.0",
"node-fetch": "^3.3.0",
@@ -53,7 +56,8 @@
"matrix-get-event": "src/matrix-get-event.js",
"matrix-event-relations": "src/matrix-event-relations.js",
"matrix-verification": "src/matrix-verification.js",
"matrix-verification-action": "src/matrix-verification-action.js"
"matrix-verification-action": "src/matrix-verification-action.js",
"matrix-send-location": "src/matrix-send-location.js"
}
},
"engines": {
+74 -1
View File
@@ -17,6 +17,13 @@ const v8 = require('v8');
let shimInstalled = false;
// Mirrors matrix-js-sdk's localStorage-crypto-store.js: olm sessions live under
// "crypto.sessions/<deviceKey>" and migration batches are capped at 50.
const E2E_PREFIX = 'crypto.';
const SESSION_KEY_PREFIX = E2E_PREFIX + 'sessions/';
const SESSION_BATCH_SIZE = 50;
const OLM_BATCH_PATCH_MARKER = Symbol.for('node-red-contrib-matrix-chat.olmSessionBatchPatched');
/**
* Install the in-memory IndexedDB shim onto globalThis. Idempotent. Must be
* called before MatrixClient.initRustCrypto().
@@ -172,4 +179,70 @@ async function snapshotCryptoStore(filePath, dbNamePrefix) {
return true;
}
module.exports = { ensureIndexedDBShim, restoreCryptoStore, snapshotCryptoStore };
/**
* Patch a LocalStorageCryptoStore instance so its getEndToEndSessionsBatch()
* returns olm sessions with deviceKey/sessionId attached.
*
* Why: matrix-js-sdk's LocalStorageCryptoStore stores olm sessions as
* `{ session, lastReceivedMessageTs }` with the curve25519 deviceKey encoded
* only in the localStorage key ("crypto.sessions/<deviceKey>"). On read,
* getEndToEndSessionsBatch() returns the bare session value without injecting
* the deviceKey or sessionId, so initRustCrypto()'s libolm-to-rust migration
* crashes at PickledSession.senderKey = session.deviceKey (undefined). The
* IndexedDB backend stores those fields in the record and is unaffected.
*
* Idempotent and safe to call on a store with no legacy sessions.
*/
function patchLocalStorageCryptoStoreForRustMigration(cryptoStore) {
if (!cryptoStore || cryptoStore[OLM_BATCH_PATCH_MARKER]) {
return cryptoStore;
}
const store = cryptoStore.store;
if (!store || typeof store.length !== 'number' || typeof store.key !== 'function') {
return cryptoStore;
}
cryptoStore.getEndToEndSessionsBatch = async function() {
const result = [];
for (let i = 0; i < store.length; i++) {
const key = store.key(i);
if (!key || !key.startsWith(SESSION_KEY_PREFIX)) {
continue;
}
const deviceKey = key.slice(SESSION_KEY_PREFIX.length);
let sessions;
try {
const raw = store.getItem(key);
sessions = raw ? JSON.parse(raw) : null;
} catch (e) {
sessions = null;
}
if (!sessions || typeof sessions !== 'object') {
continue;
}
for (const [sessionId, val] of Object.entries(sessions)) {
if (val === null || val === undefined) {
continue;
}
// Mirrors LocalStorageCryptoStore._getEndToEndSessions: very old
// entries were stored as bare base64 pickle strings.
const sessionInfo = (typeof val === 'string')
? { session: val, lastReceivedMessageTs: 0 }
: val;
result.push({ ...sessionInfo, deviceKey, sessionId });
if (result.length >= SESSION_BATCH_SIZE) {
return result;
}
}
}
return result.length === 0 ? null : result;
};
cryptoStore[OLM_BATCH_PATCH_MARKER] = true;
return cryptoStore;
}
module.exports = {
ensureIndexedDBShim,
restoreCryptoStore,
snapshotCryptoStore,
patchLocalStorageCryptoStoreForRustMigration,
};
+385
View File
@@ -0,0 +1,385 @@
// Markdown -> HTML converter for matrix messages.
//
// Ported from matrix-react-sdk's `src/Markdown.ts` (now living at
// element-hq/element-web `apps/web/src/Markdown.ts`) so the HTML this module
// generates lines up with what Element produces for the same markdown source.
//
// Keep this in sync with element-web's Markdown.ts when noticeable changes
// land there. Source of truth:
// https://github.com/element-hq/element-web/blob/develop/apps/web/src/Markdown.ts
//
// Copyright 2024 New Vector Ltd.
// Copyright 2021 The Matrix.org Foundation C.I.C.
// Copyright 2016 OpenMarket Ltd
//
// SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
const commonmark = require("commonmark");
const escape = require("lodash.escape");
const linkify = require("linkifyjs");
const ALLOWED_HTML_TAGS = ["sub", "sup", "del", "s", "u", "br", "br/"];
// These types of node are definitely text
const TEXT_NODES = ["text", "softbreak", "linebreak", "paragraph", "document"];
function isAllowedHtmlTag(node) {
if (!node.literal) {
return false;
}
if (node.literal.match('^<((div|span) data-mx-maths="[^"]*"|/(div|span))>$') != null) {
return true;
}
// Regex won't work for tags with attrs, but the tags we allow
// shouldn't really have any anyway.
const matches = /^<\/?(.*)>$/.exec(node.literal);
if (matches && matches.length == 2) {
const tag = matches[1];
return ALLOWED_HTML_TAGS.indexOf(tag) > -1;
}
return false;
}
/*
* Returns true if the parse output containing the node
* comprises multiple block level elements (ie. lines),
* or false if it is only a single line.
*/
function isMultiLine(node) {
let par = node;
while (par.parent) {
par = par.parent;
}
return par.firstChild != par.lastChild;
}
function getTextUntilEndOrLinebreak(node) {
let currentNode = node;
let text = "";
while (currentNode && currentNode.type !== "softbreak" && currentNode.type !== "linebreak") {
const { literal, type } = currentNode;
if (type === "text" && literal) {
let n = 0;
let char = literal[n];
while (char !== " " && char !== null && n <= literal.length) {
if (char === " ") {
break;
}
if (char) {
text += char;
}
n += 1;
char = literal[n];
}
if (char === " ") {
break;
}
}
currentNode = currentNode.next;
}
return text;
}
const formattingChangesByNodeType = {
emph: "_",
strong: "__",
};
/**
* Returns the literal of a node and all child nodes.
*/
const innerNodeLiteral = (node) => {
let literal = "";
const walker = node.walker();
let step;
while ((step = walker.next())) {
const currentNode = step.node;
const currentNodeLiteral = currentNode.literal;
if (step.entering && currentNode.type === "text" && currentNodeLiteral) {
literal += currentNodeLiteral;
}
}
return literal;
};
const emptyItemWithNoSiblings = (node) => {
return !node.prev && !node.next && !node.firstChild;
};
/**
* Class that wraps commonmark, adding the ability to see whether
* a given message actually uses any markdown syntax or whether
* it's plain text.
*/
class Markdown {
constructor(input) {
this.input = input;
const parser = new commonmark.Parser();
this.parsed = parser.parse(this.input);
this.parsed = this.repairLinks(this.parsed);
}
/**
* This method is modifying the parsed AST in such a way that links are always
* properly linkified instead of sometimes being wrongly emphasised in case
* if you were to write a link like the example below:
* https://my_weird-link_domain.domain.com
* ^ this link would be parsed to something like this:
* <a href="https://my">https://my</a><b>weird-link</b><a href="https://domain.domain.com">domain.domain.com</a>
* This method makes it so the link gets properly modified to a version where it is
* not emphasised until it actually ends.
* See: https://github.com/vector-im/element-web/issues/4674
*/
repairLinks(parsed) {
const walker = parsed.walker();
let event = null;
let text = "";
let isInPara = false;
let previousNode = null;
let shouldUnlinkFormattingNode = false;
while ((event = walker.next())) {
const { node } = event;
if (node.type === "paragraph") {
isInPara = !!event.entering;
}
if (isInPara) {
// Clear saved string when line ends
if (
node.type === "softbreak" ||
node.type === "linebreak" ||
// Also start calculating the text from the beginning on any spaces
(node.type === "text" && node.literal === " ")
) {
text = "";
continue;
}
// Break up text nodes on spaces, so that we don't shoot past them without resetting
if (node.type === "text" && node.literal) {
const [thisPart, ...nextParts] = node.literal.split(/( )/);
node.literal = thisPart;
text += thisPart;
// Add the remaining parts as siblings
nextParts.reverse().forEach((part) => {
if (part) {
const nextNode = new commonmark.Node("text");
nextNode.literal = part;
node.insertAfter(nextNode);
// Make the iterator aware of the newly inserted node
walker.resumeAt(nextNode, true);
}
});
}
// We should not do this if previous node was not a textnode, as we can't combine it then.
if (
(node.type === "emph" || node.type === "strong") &&
previousNode && previousNode.type === "text"
) {
if (event.entering) {
const foundLinks = linkify.find(text);
for (const { value } of foundLinks) {
if (node && node.firstChild && node.firstChild.literal) {
/**
* NOTE: This technically should unlink the emph node and create LINK nodes instead, adding all the next elements as siblings
* but this solution seems to work well and is hopefully slightly easier to understand too
*/
const format = formattingChangesByNodeType[node.type];
const nonEmphasizedText = `${format}${innerNodeLiteral(node)}${format}`;
const f = getTextUntilEndOrLinebreak(node);
const newText = value + nonEmphasizedText + f;
const newLinks = linkify.find(newText);
// Should always find only one link here, if it finds more it means that the algorithm is broken
if (newLinks.length === 1) {
const emphasisTextNode = new commonmark.Node("text");
emphasisTextNode.literal = nonEmphasizedText;
previousNode.insertAfter(emphasisTextNode);
node.firstChild.literal = "";
event = node.walker().next();
if (event) {
// Remove `em` opening and closing nodes
node.unlink();
previousNode.insertAfter(event.node);
shouldUnlinkFormattingNode = true;
}
} else {
console.warn(
"matrix-chat markdown: link escaping found too many links for text:",
text,
"modified:",
newText,
);
}
}
}
} else {
if (shouldUnlinkFormattingNode) {
node.unlink();
shouldUnlinkFormattingNode = false;
}
}
}
}
previousNode = node;
}
return parsed;
}
isPlainText() {
const walker = this.parsed.walker();
let ev;
while ((ev = walker.next())) {
const node = ev.node;
if (TEXT_NODES.indexOf(node.type) > -1) {
// definitely text
continue;
} else if (node.type == "list" || node.type == "item") {
// Special handling for inputs like `+`, `*`, `-` and `2021.` which
// would otherwise be treated as a list of a single empty item.
// See https://github.com/vector-im/element-web/issues/7631
if (
node.type == "list" &&
node.firstChild &&
emptyItemWithNoSiblings(node.firstChild)
) {
// A list with a single empty item is treated as plain text.
continue;
}
if (node.type == "item" && emptyItemWithNoSiblings(node)) {
// An empty list item with no sibling items is treated as plain text.
continue;
}
// Everything else is actual lists and therefore not plaintext.
return false;
} else if (node.type == "html_inline" || node.type == "html_block") {
// if it's an allowed html tag, we need to render it and therefore
// we will need to use HTML. If it's not allowed, it's not HTML since
// we'll just be treating it as text.
if (isAllowedHtmlTag(node)) {
return false;
}
} else {
return false;
}
}
return true;
}
toHTML({ externalLinks = false } = {}) {
const renderer = new commonmark.HtmlRenderer({
safe: false,
// Set soft breaks to hard HTML breaks: commonmark
// puts softbreaks in for multiple lines in a blockquote,
// so if these are just newline characters then the
// block quote ends up all on one line
// (https://github.com/vector-im/element-web/issues/3154)
softbreak: "<br />",
});
// Trying to strip out the wrapping <p/> causes a lot more complication
// than it's worth, i think. For instance, this code will go and strip
// out any <p/> tag (no matter where it is in the tree) which doesn't
// contain \n's.
// On the flip side, <p/>s are quite opionated and restricted on where
// you can nest them.
//
// Let's try sending with <p/>s anyway for now, though.
const realParagraph = renderer.paragraph;
renderer.paragraph = function (node, entering) {
// If there is only one top level node, just return the
// bare text: it's a single line of text and so should be
// 'inline', rather than unnecessarily wrapped in its own
// p tag. If, however, we have multiple nodes, each gets
// its own p tag to keep them as separate paragraphs.
// However, if it's a blockquote, adds a p tag anyway
// in order to avoid deviation to commonmark and unexpected
// results when parsing the formatted HTML.
if ((node.parent && node.parent.type === "block_quote") || isMultiLine(node)) {
realParagraph.call(this, node, entering);
}
};
renderer.link = function (node, entering) {
const attrs = this.attrs(node);
if (entering && node.destination) {
attrs.push(["href", this.esc(node.destination)]);
if (node.title) {
attrs.push(["title", this.esc(node.title)]);
}
// Modified link behaviour to treat them all as external and
// thus opening in a new tab.
if (externalLinks) {
attrs.push(["target", "_blank"]);
attrs.push(["rel", "noreferrer noopener"]);
}
this.tag("a", attrs);
} else {
this.tag("/a");
}
};
renderer.html_inline = function (node) {
if (node.literal) {
if (isAllowedHtmlTag(node)) {
this.lit(node.literal);
} else {
this.lit(escape(node.literal));
}
}
};
renderer.html_block = function (node) {
renderer.html_inline(node);
};
return renderer.render(this.parsed);
}
/*
* Render the markdown message to plain text. That is, essentially
* just remove any backslashes escaping what would otherwise be
* markdown syntax
* (to fix https://github.com/vector-im/element-web/issues/2870).
*
* N.B. this does **NOT** render arbitrary MD to plain text - only MD
* which has no formatting. Otherwise it emits HTML(!).
*/
toPlaintext() {
const renderer = new commonmark.HtmlRenderer({ safe: false });
renderer.paragraph = function (node, entering) {
// as with toHTML, only append lines to paragraphs if there are
// multiple paragraphs
if (isMultiLine(node)) {
if (!entering && node.next) {
this.lit("\n\n");
}
}
};
renderer.html_block = function (node) {
if (node.literal) this.lit(node.literal);
if (isMultiLine(node) && node.next) this.lit("\n\n");
};
// We inhibit the default escape function as we escape the entire output string to correctly handle backslashes
renderer.esc = (input) => input;
return escape(renderer.render(this.parsed));
}
}
module.exports = { Markdown };
+28 -1
View File
@@ -357,9 +357,36 @@
</li>
<li><code>msg.type</code> == '<strong>m.location</strong>'
<p>
The structured location fields are surfaced at the top level of
<code>msg</code> so the message can be wired straight into a
<code>matrix-send-location</code> node to resend the location
without any field translation in between.
</p>
<dl class="message-properties">
<dt>msg.geo_uri <span class="property-type">string</span></dt>
<dd>URI format of the geolocation</dd>
<dd>The RFC&nbsp;5870 geo URI from the event (e.g. <code>geo:48.85,2.35</code>).</dd>
<dt>msg.latitude <span class="property-type">number</span></dt>
<dd>Latitude in decimal degrees, parsed from the geo URI.</dd>
<dt>msg.longitude <span class="property-type">number</span></dt>
<dd>Longitude in decimal degrees, parsed from the geo URI.</dd>
<dt class="optional">msg.altitude <span class="property-type">number</span></dt>
<dd>Metres above sea level, parsed from the geo URI. Only set when the geo URI includes an altitude component (<code>geo:lat,lng,alt</code>).</dd>
<dt class="optional">msg.description <span class="property-type">string</span></dt>
<dd>The location's label (e.g. <em>Eiffel Tower</em>). Only set when the sender included one.</dd>
<dt>msg.assetType <span class="property-type">string</span></dt>
<dd><code>"m.self"</code> when the sender was sharing their own location, <code>"m.pin"</code> for a generic dropped pin. Defaults to <code>"m.self"</code> when the event does not carry an explicit asset type (per the Matrix spec).</dd>
<dt class="optional">msg.timestamp <span class="property-type">number</span></dt>
<dd>Milliseconds since the UNIX epoch when the location was correct (the event's <code>m.ts</code> field). Only set when the sender included a timestamp.</dd>
<dt>msg.payload <span class="property-type">string</span></dt>
<dd>The event's <code>body</code> &mdash; a human-readable text fallback for clients that cannot render the map snippet.</dd>
</dl>
</li>
</ul>
+34 -1
View File
@@ -1,3 +1,16 @@
// Parse an RFC 5870 geo URI into {latitude, longitude, altitude?}.
// Returns null if the URI is missing or malformed.
function parseGeoUri(uri) {
if (typeof uri !== "string" || uri.indexOf("geo:") !== 0) return null;
// strip any ";u=..." / ";crs=..." parameters
const body = uri.slice(4).split(";")[0];
const parts = body.split(",").map(function(s) { return parseFloat(s.trim()); });
if (parts.length < 2 || !Number.isFinite(parts[0]) || !Number.isFinite(parts[1])) return null;
const result = { latitude: parts[0], longitude: parts[1] };
if (parts.length >= 3 && Number.isFinite(parts[2])) result.altitude = parts[2];
return result;
}
module.exports = function(RED) {
function MatrixReceiveMessage(n) {
RED.nodes.createNode(this, n);
@@ -146,11 +159,31 @@ module.exports = function(RED) {
setThumbnailUrls('thumbnail_file');
break;
case 'm.location':
case 'm.location': {
if (!node.acceptLocations) return;
msg.geo_uri = msg.content.geo_uri;
msg.payload = msg.content.body;
// Surface the structured location fields at the top level
// so a `matrix-send-location` node wired straight to this
// output resends the same location. Both the stable
// (m.location / m.asset / m.ts) and the MSC3488-prefixed
// namespaces are checked, since Element currently emits
// the prefixed form even though the spec is stable.
const loc = msg.content["m.location"] || msg.content["org.matrix.msc3488.location"] || {};
const asset = msg.content["m.asset"] || msg.content["org.matrix.msc3488.asset"] || {};
let ts = msg.content["m.ts"];
if (typeof ts !== "number") ts = msg.content["org.matrix.msc3488.ts"];
const coords = parseGeoUri(loc.uri || msg.geo_uri);
if (coords) {
msg.latitude = coords.latitude;
msg.longitude = coords.longitude;
if (coords.altitude !== undefined) msg.altitude = coords.altitude;
}
if (loc.description) msg.description = loc.description;
msg.assetType = asset.type || "m.self";
if (typeof ts === "number") msg.timestamp = ts;
break;
}
case 'm.reaction':
if (!node.acceptReactions) return;
+263
View File
@@ -0,0 +1,263 @@
<script type="text/javascript">
RED.nodes.registerType('matrix-send-location', {
category: 'matrix',
color: '#00b7ca',
defaults: {
name: { value: "" },
server: { type: "matrix-server-config", required: true },
roomId: { value: "" },
latitudeType: { value: "msg" },
latitudeValue: { value: "latitude" },
longitudeType: { value: "msg" },
longitudeValue: { value: "longitude" },
altitudeType: { value: "msg" },
altitudeValue: { value: "altitude" },
geoUriType: { value: "msg" },
geoUriValue: { value: "geo_uri" },
descriptionType: { value: "msg" },
descriptionValue: { value: "description" },
assetTypeType: { value: "msg" },
assetTypeValue: { value: "assetType" },
timestampType: { value: "msg" },
timestampValue: { value: "timestamp" },
textType: { value: "msg" },
textValue: { value: "payload" }
},
inputs: 1,
outputs: 2,
outputLabels: ["success", "error"],
icon: "matrix.png",
label: function() {
return this.name || "Send Location";
},
paletteLabel: 'Send Location',
oneditprepare: function() {
var numTypes = ["msg", "flow", "global", "num"];
var strTypes = ["msg", "flow", "global", "str"];
$("#node-input-latitudeValue").typedInput({
typeField: "#node-input-latitudeType",
types: numTypes,
default: "msg"
});
$("#node-input-longitudeValue").typedInput({
typeField: "#node-input-longitudeType",
types: numTypes,
default: "msg"
});
$("#node-input-altitudeValue").typedInput({
typeField: "#node-input-altitudeType",
types: numTypes,
default: "msg"
});
$("#node-input-geoUriValue").typedInput({
typeField: "#node-input-geoUriType",
types: strTypes,
default: "msg"
});
$("#node-input-descriptionValue").typedInput({
typeField: "#node-input-descriptionType",
types: strTypes,
default: "msg"
});
$("#node-input-assetTypeValue").typedInput({
typeField: "#node-input-assetTypeType",
types: [
{
value: "str",
label: "string",
options: [
{ value: "m.self", label: "My location (m.self)" },
{ value: "m.pin", label: "Pin (m.pin)" }
]
},
"msg",
"flow",
"global"
],
default: "msg"
});
$("#node-input-timestampValue").typedInput({
typeField: "#node-input-timestampType",
types: numTypes,
default: "msg"
});
$("#node-input-textValue").typedInput({
typeField: "#node-input-textType",
types: strTypes,
default: "msg"
});
}
});
</script>
<script type="text/html" data-template-name="matrix-send-location">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-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-server"></i> Server</label>
<input type="text" id="node-input-server">
</div>
<div class="form-row">
<label for="node-input-roomId"><i class="fa fa-comments"></i> Room ID</label>
<input type="text" id="node-input-roomId" placeholder="!room:matrix.org">
</div>
<div class="form-tips" style="margin-bottom:12px;">Optional. If empty, <code>msg.topic</code> is used.</div>
<div class="form-row">
<label for="node-input-latitudeValue"><i class="fa fa-crosshairs"></i> Latitude</label>
<input type="hidden" id="node-input-latitudeType">
<input type="text" id="node-input-latitudeValue" style="width:70%">
</div>
<div class="form-row">
<label for="node-input-longitudeValue"><i class="fa fa-crosshairs"></i> Longitude</label>
<input type="hidden" id="node-input-longitudeType">
<input type="text" id="node-input-longitudeValue" style="width:70%">
</div>
<div class="form-row">
<label for="node-input-altitudeValue"><i class="fa fa-arrow-up"></i> Altitude</label>
<input type="hidden" id="node-input-altitudeType">
<input type="text" id="node-input-altitudeValue" style="width:70%">
</div>
<div class="form-tips" style="margin-bottom:12px;">
Optional. Metres above sea level. When provided the geo URI becomes <code>geo:lat,lng,alt</code>.
</div>
<div class="form-row">
<label for="node-input-geoUriValue"><i class="fa fa-map"></i> geo_uri</label>
<input type="hidden" id="node-input-geoUriType">
<input type="text" id="node-input-geoUriValue" style="width:70%">
</div>
<div class="form-tips" style="margin-bottom:12px;">
Optional. A pre-built RFC&nbsp;5870 geo URI (e.g. <code>geo:48.85,2.35</code>). When set, Latitude / Longitude / Altitude are ignored.
</div>
<div class="form-row">
<label for="node-input-descriptionValue"><i class="fa fa-info-circle"></i> Description</label>
<input type="hidden" id="node-input-descriptionType">
<input type="text" id="node-input-descriptionValue" style="width:70%">
</div>
<div class="form-tips" style="margin-bottom:12px;">
Optional. Label for the location (e.g. <em>Eiffel Tower</em>).
</div>
<div class="form-row">
<label for="node-input-assetTypeValue"><i class="fa fa-map-marker"></i> Type</label>
<input type="hidden" id="node-input-assetTypeType">
<input type="text" id="node-input-assetTypeValue" style="width:70%">
</div>
<div class="form-tips" style="margin-bottom:12px;">
<b>My location</b> (<code>m.self</code>) says "this is where I am"; <b>Pin</b> (<code>m.pin</code>) marks a generic pinned point.
</div>
<div class="form-row">
<label for="node-input-timestampValue"><i class="fa fa-clock-o"></i> Timestamp</label>
<input type="hidden" id="node-input-timestampType">
<input type="text" id="node-input-timestampValue" style="width:70%">
</div>
<div class="form-tips" style="margin-bottom:12px;">
Optional. Milliseconds since the UNIX epoch. Defaults to <em>now</em> when empty.
</div>
<div class="form-row">
<label for="node-input-textValue"><i class="fa fa-file-text-o"></i> Body / text</label>
<input type="hidden" id="node-input-textType">
<input type="text" id="node-input-textValue" style="width:70%">
</div>
<div class="form-tips">
Optional. Override the auto-generated text fallback used in <code>body</code> and <code>m.text</code> for clients that can't render the map.
</div>
</script>
<script type="text/html" data-help-name="matrix-send-location">
<h3>Details</h3>
<p>Sends an m.location event to a Matrix room.</p>
<p>
Produces an <code>m.location</code> message just like Element's
<em>"Share my location"</em> feature, so the location renders as a map
snippet in clients that support it. The event content matches what
Element sends &mdash; legacy <code>geo_uri</code> + <code>body</code>
fields for older clients, plus the modern extensible-events fields
(<code>m.location</code>, <code>m.asset</code>, <code>m.text</code>,
<code>m.ts</code>) for newer ones. Built with
<code>matrix-js-sdk</code>'s <code>makeLocationContent</code> helper so
the wire format stays in lockstep with Element's.
</p>
<p>
Every input below is a <strong>typed input</strong>: pick the source
(<code>msg</code>, <code>flow</code>, <code>global</code>,
<code>num</code>, or <code>str</code>) and the value. The defaults
read from <code>msg.*</code> using the documented names so the simple
case <em>just works</em> &mdash; reach for the type selector when you
want to pull from a flow / global variable or pin a static value on
the node itself.
</p>
<h4>Configuration</h4>
<dl class="message-properties">
<dt>Server</dt>
<dd>The Matrix server config node.</dd>
<dt>Room ID</dt>
<dd>Optional default room. If empty, <code>msg.topic</code> is used.</dd>
<dt>Latitude <span class="property-type">number</span></dt>
<dd>Decimal degrees, between -90 and 90. Default: <code>msg.latitude</code>.</dd>
<dt>Longitude <span class="property-type">number</span></dt>
<dd>Decimal degrees, between -180 and 180. Default: <code>msg.longitude</code>.</dd>
<dt>Altitude <span class="property-type">number, optional</span></dt>
<dd>Metres above sea level. When provided, the geo URI becomes <code>geo:lat,lng,alt</code>. Default: <code>msg.altitude</code>.</dd>
<dt>geo_uri <span class="property-type">string, optional</span></dt>
<dd>A pre-built RFC&nbsp;5870 geo URI (e.g. <code>geo:48.85,2.35</code>). When set, the Latitude / Longitude / Altitude inputs are ignored. Default: <code>msg.geo_uri</code>.</dd>
<dt>Description <span class="property-type">string, optional</span></dt>
<dd>Label for the location (e.g. <em>Eiffel Tower</em>). Default: <code>msg.description</code>.</dd>
<dt>Type</dt>
<dd>
<b>My location</b> (<code>m.self</code>) marks the location as
"this is where I am right now" (the sender's location).
<b>Pin</b> (<code>m.pin</code>) marks it as a generic pinned
point. Defaults to <code>msg.assetType</code> (which the Receive
node populates for incoming location events, so a Receive node
wired straight to this one resends with the same asset type);
falls back to <code>m.self</code> when not provided. Flip the
type selector to <code>string</code> to pin a static value.
</dd>
<dt>Timestamp <span class="property-type">number, optional</span></dt>
<dd>Milliseconds since the UNIX epoch &mdash; when the location was correct. Defaults to <em>now</em>. Default source: <code>msg.timestamp</code>.</dd>
<dt>Body / text <span class="property-type">string, optional</span></dt>
<dd>Override the auto-generated text fallback used in <code>body</code> and <code>m.text</code>. If left empty, a sensible default like <code>User Location "Eiffel Tower" geo:48.85,2.35 at 2026-05-23T04:44:41Z</code> is generated. Default source: <code>msg.payload</code> (matches what the Receive node sets for incoming events).</dd>
</dl>
<h4>Inputs</h4>
<dl class="message-properties">
<dt>msg.topic <span class="property-type">string</span></dt>
<dd>Room ID to send the location to. Used when Room ID is not set on the node.</dd>
<dt class="optional">msg.&lt;configured&gt;</dt>
<dd>The specific message keys depend on the node configuration. By default the node reads <code>msg.latitude</code>, <code>msg.longitude</code>, <code>msg.altitude</code>, <code>msg.geo_uri</code>, <code>msg.description</code>, <code>msg.assetType</code>, <code>msg.timestamp</code>, and <code>msg.payload</code> &mdash; the same field names the Receive node populates for incoming <code>m.location</code> events, so a Receive node wired straight to this one re-sends the location verbatim. Rename or pull from <code>flow</code>/<code>global</code> via the typed-input selectors.</dd>
</dl>
<h4>Outputs</h4>
<p>Two outputs &mdash; <b>success</b> (top) and <b>error</b> (bottom).</p>
<dl class="message-properties">
<dt>msg.eventId <span class="property-type">string</span></dt>
<dd>The event ID of the sent location message (on the success output).</dd>
<dt>msg.payload <span class="property-type">object</span></dt>
<dd>The full content object that was sent to the room.</dd>
<dt>msg.error <span class="property-type">object</span></dt>
<dd>The error (on the error output, when sending fails).</dd>
</dl>
</script>
+202
View File
@@ -0,0 +1,202 @@
// matrix-js-sdk's main entry does not re-export `makeLocationContent`, so we
// deep-import the content-helpers module. The SDK has no `exports` field in
// its package.json (verified against v41) so subpath imports are stable.
const contentHelpersPromise = import("matrix-js-sdk/lib/content-helpers.js");
module.exports = function(RED) {
const VALID_ASSET_TYPES = ["m.self", "m.pin"];
function MatrixSendLocation(n) {
RED.nodes.createNode(this, n);
let node = this;
this.name = n.name;
this.server = RED.nodes.getNode(n.server);
this.roomId = n.roomId;
// Dynamic inputs: each has a `*Type` (msg | flow | global | str | num)
// and a `*Value` (the property path or literal). Defaults preserve the
// documented per-message names so existing flows keep working.
this.latitudeType = n.latitudeType || "msg";
this.latitudeValue = n.latitudeValue || "latitude";
this.longitudeType = n.longitudeType || "msg";
this.longitudeValue = n.longitudeValue || "longitude";
this.altitudeType = n.altitudeType || "msg";
this.altitudeValue = n.altitudeValue || "altitude";
this.geoUriType = n.geoUriType || "msg";
this.geoUriValue = n.geoUriValue || "geo_uri";
this.descriptionType = n.descriptionType || "msg";
this.descriptionValue = n.descriptionValue || "description";
this.assetTypeType = n.assetTypeType || "msg";
this.assetTypeValue = n.assetTypeValue || "assetType";
this.timestampType = n.timestampType || "msg";
this.timestampValue = n.timestampValue || "timestamp";
this.textType = n.textType || "msg";
this.textValue = n.textValue || "payload";
if (!node.server) {
node.warn("No configuration node");
return;
}
node.server.register(node);
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" });
});
/**
* Resolve a typed-input pair to its runtime value.
* msg | flow | global - read the property path from that source.
* num - parse the configured literal as a number;
* empty string => undefined.
* str - the configured literal; empty string => undefined.
* bool - "true" => true, anything else => false.
*
* Returning `undefined` from this signals "not provided", which is
* how optional fields opt out.
*/
function getToValue(msg, type, property) {
if (type === "msg") {
return RED.util.getMessageProperty(msg, property);
}
if (type === "flow" || type === "global") {
try {
return RED.util.evaluateNodeProperty(property, type, node, msg);
} catch (e) {
throw new Error("Invalid " + type + " value evaluation for '" + property + "'");
}
}
if (property === "" || property === undefined || property === null) {
return undefined;
}
if (type === "num") {
const n = Number(property);
return Number.isFinite(n) ? n : undefined;
}
if (type === "bool") {
return property === "true";
}
// str / default
return property;
}
function isEmpty(v) {
return v === undefined || v === null || v === "";
}
node.on("input", async 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", msg);
node.send([null, msg]);
return;
}
msg.topic = node.roomId || msg.topic;
if (!msg.topic) {
node.error("Room must be specified in msg.topic or in configuration", msg);
return;
}
// Resolve every typed input up-front so a single bad config or
// flow/global lookup surfaces as one clear error.
let rawLat, rawLng, rawAlt, rawGeoUri, description, assetType, rawTimestamp, text;
try {
rawLat = getToValue(msg, node.latitudeType, node.latitudeValue);
rawLng = getToValue(msg, node.longitudeType, node.longitudeValue);
rawAlt = getToValue(msg, node.altitudeType, node.altitudeValue);
rawGeoUri = getToValue(msg, node.geoUriType, node.geoUriValue);
description = getToValue(msg, node.descriptionType, node.descriptionValue);
assetType = getToValue(msg, node.assetTypeType, node.assetTypeValue);
rawTimestamp = getToValue(msg, node.timestampType, node.timestampValue);
text = getToValue(msg, node.textType, node.textValue);
} catch (e) {
node.error(e.message, msg);
node.send([null, msg]);
return;
}
// Build the geo URI: prefer an explicit geo_uri when supplied;
// otherwise build geo:<lat>,<lng>[,<alt>] from numeric inputs.
let geoUri = isEmpty(rawGeoUri) ? null : String(rawGeoUri);
if (!geoUri) {
const lat = parseFloat(rawLat);
const lng = parseFloat(rawLng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
node.error("Latitude and longitude (numbers) - or a geo_uri - are required", msg);
node.send([null, msg]);
return;
}
if (lat < -90 || lat > 90) {
node.error("Latitude (" + lat + ") is out of range; must be between -90 and 90", msg);
node.send([null, msg]);
return;
}
if (lng < -180 || lng > 180) {
node.error("Longitude (" + lng + ") is out of range; must be between -180 and 180", msg);
node.send([null, msg]);
return;
}
geoUri = "geo:" + lat + "," + lng;
if (!isEmpty(rawAlt)) {
const alt = parseFloat(rawAlt);
if (!Number.isFinite(alt)) {
node.error("Altitude must be a number when provided", msg);
node.send([null, msg]);
return;
}
geoUri = "geo:" + lat + "," + lng + "," + alt;
}
}
// Asset type defaults to m.self if the resolved value is empty.
if (isEmpty(assetType)) {
assetType = "m.self";
}
if (VALID_ASSET_TYPES.indexOf(assetType) === -1) {
node.error('Invalid asset type "' + assetType + '"; must be "m.self" or "m.pin"', msg);
node.send([null, msg]);
return;
}
// Timestamp the location was correct, in ms since the UNIX epoch.
let timestamp = Date.now();
if (!isEmpty(rawTimestamp)) {
const ts = Number(rawTimestamp);
if (Number.isFinite(ts)) {
timestamp = ts;
}
}
// makeLocationContent uses `undefined` to mean "generate a default".
const cleanDescription = isEmpty(description) ? undefined : String(description);
const cleanText = isEmpty(text) ? undefined : String(text);
try {
const { makeLocationContent } = await contentHelpersPromise;
const content = makeLocationContent(cleanText, geoUri, timestamp, cleanDescription, assetType);
const response = await node.server.matrixClient.sendMessage(msg.topic, content);
msg.eventId = response.event_id;
msg.payload = content;
node.send([msg, null]);
} catch (e) {
node.error("Error sending location: " + e, msg);
msg.error = e;
node.send([null, msg]);
}
});
node.on("close", function() {
node.server.deregister(node);
});
}
RED.nodes.registerType("matrix-send-location", MatrixSendLocation);
};
+31 -2
View File
@@ -99,6 +99,7 @@
</label>
<select id="node-input-messageFormat">
<option value="">Default (plaintext)</option>
<option value="markdown">Markdown</option>
<option value="html">HTML</option>
<option value="msg.format">msg.format input</option>
</select>
@@ -140,7 +141,7 @@
<dt class="optional">msg.formatted_payload
<span class="property-type">string</span>
</dt>
<dd> the formatted HTML message (uses <code>msg.payload</code> if not defined). This only affects HTML messages.</dd>
<dd> the formatted HTML message (uses <code>msg.payload</code> if not defined). This only affects messages sent in <strong>HTML</strong> format &mdash; in Markdown mode the formatted body is generated from the markdown source.</dd>
<dt class="optional">msg.type
<span class="property-type">string | null</span>
@@ -150,7 +151,35 @@
<dt class="optional">msg.format
<span class="property-type">string | null</span>
</dt>
<dd> This is only used and required when configured so on the node. Set to <code>null</code> for plain text and <code>'html'</code> for HTML.</dd>
<dd> This is only used and required when configured so on the node. Set to <code>null</code> for plain text, <code>'markdown'</code> for markdown (converted to HTML the same way Element does), or <code>'html'</code> for HTML.</dd>
</dl>
<h4>Message formats</h4>
<dl class="message-properties">
<dt>Default (plaintext)</dt>
<dd>The payload is sent as-is as the message body.</dd>
<dt>Markdown</dt>
<dd>
The payload is parsed as CommonMark markdown and converted to HTML
the same way Element does (using the same converter ported from
<code>matrix-react-sdk</code>). If the message turns out to contain
no markdown syntax it is sent as plain text; otherwise the original
markdown source becomes the message <code>body</code> and the
rendered HTML is sent as <code>formatted_body</code>, so clients
without HTML rendering still see a readable fallback.
</dd>
<dt>HTML</dt>
<dd>
The payload is sent as HTML. By default the same HTML is used for
both the plain-text and formatted versions; set
<code>msg.formatted_payload</code> if you want the
<code>formatted_body</code> to differ from <code>msg.payload</code>.
</dd>
<dt>msg.format input</dt>
<dd>Set <code>msg.format</code> at runtime to one of the options above (<code>null</code>, <code>'markdown'</code>, or <code>'html'</code>).</dd>
</dl>
<h3>Outputs</h3>
+23 -1
View File
@@ -1,4 +1,5 @@
const sdkPromise = import("matrix-js-sdk");
const { Markdown } = require("./matrix-markdown");
module.exports = function(RED) {
function MatrixSendImage(n) {
@@ -143,7 +144,28 @@ module.exports = function(RED) {
body: payload.toString()
};
if (msgFormat === 'html') {
if (msgFormat === 'markdown') {
// Convert the markdown body to HTML using the same logic
// as Element (matrix-react-sdk's `Markdown` class).
//
// If the message contains any markdown syntax, send the
// rendered HTML as `formatted_body` and keep the original
// markdown source as `body` (matrix spec convention for
// formatted messages). If the message turns out to be
// plain text and contains backslash escapes, strip those
// from `body` and send no HTML; otherwise leave `body`
// as the original payload.
const source = payload.toString();
const md = new Markdown(source);
if (md.isPlainText()) {
if (source.indexOf("\\") > -1) {
content.body = md.toPlaintext();
}
} else {
content.format = "org.matrix.custom.html";
content.formatted_body = md.toHTML();
}
} else if (msgFormat === 'html') {
content.format = "org.matrix.custom.html";
content.formatted_body =
(typeof msg.formatted_payload !== 'undefined' && msg.formatted_payload)
+13 -3
View File
@@ -8,7 +8,12 @@ const cryptoApiPromise = import("matrix-js-sdk/lib/crypto-api/index.js");
const fs = require("fs-extra");
const { resolve } = require('path');
const { LocalStorage } = require('node-localstorage');
const { ensureIndexedDBShim, restoreCryptoStore, snapshotCryptoStore } = require('./matrix-crypto-store');
const {
ensureIndexedDBShim,
restoreCryptoStore,
snapshotCryptoStore,
patchLocalStorageCryptoStoreForRustMigration,
} = require('./matrix-crypto-store');
require("abort-controller/polyfill"); // polyfill abort-controller if we don't have it
if (!globalThis.fetch) {
// polyfill fetch if we don't have it
@@ -453,8 +458,13 @@ module.exports = function(RED) {
if(node.e2ee) {
// Provide the legacy (pre-v37 libolm) crypto store so that
// initRustCrypto() can perform a one-time migration of any
// existing crypto state into the Rust crypto store.
clientOpts.cryptoStore = new LocalStorageCryptoStore(localStorage);
// existing crypto state into the Rust crypto store. Patch
// the store because matrix-js-sdk's LocalStorageCryptoStore
// omits deviceKey/sessionId from getEndToEndSessionsBatch(),
// which breaks the libolm->rust olm-session migration.
clientOpts.cryptoStore = patchLocalStorageCryptoStoreForRustMigration(
new LocalStorageCryptoStore(localStorage)
);
}
node.matrixClient = sdk.createClient(clientOpts);
+2 -1
View File
@@ -293,7 +293,8 @@ module.exports = function(RED) {
msg.payload.url = file.content_uri;
}
msg.payload.msgtype = msgtype;
msg.payload.body = msg.body || msg.filename || "";
msg.payload.body = msg.body || filename || "";
msg.payload.filename = filename;
msg.payload.info = {
"mimetype": contentType,
"size": getFileSize(bufferOrPath),