Compare commits

..

4 Commits

Author SHA1 Message Date
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
11 changed files with 1066 additions and 22 deletions
+12 -10
View File
@@ -1,7 +1,7 @@
# node-red-contrib-matrix-chat # 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) 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: 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)) - **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 - **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 - **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 - **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 - **Fetch/modify room state**: Update room settings
- **Paginate room history** - **Paginate room history**
- **Send files** to rooms, encrypted or unencrypted - **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** - **Send typing notifications**
- **Delete events** (messages, reactions, etc.) - **Delete events** (messages, reactions, etc.)
- **Decrypt files** in E2EE rooms - **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 ### 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. - 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. - **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. - **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 - **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. - 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). - 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).
+81 -5
View File
@@ -10,12 +10,15 @@
"license": "SEE LICENSE FILE", "license": "SEE LICENSE FILE",
"dependencies": { "dependencies": {
"abort-controller": "^3.0.0", "abort-controller": "^3.0.0",
"commonmark": "^0.31.2",
"fake-indexeddb": "^6.2.5", "fake-indexeddb": "^6.2.5",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"fs-extra": "^11.1.0", "fs-extra": "^11.1.0",
"got": "^12.0.2", "got": "^12.0.2",
"image-size": "^1.0.2", "image-size": "^1.0.2",
"isomorphic-webcrypto": "^2.3.8", "isomorphic-webcrypto": "^2.3.8",
"linkifyjs": "^4.3.3",
"lodash.escape": "^4.0.1",
"matrix-js-sdk": "^41.5.0", "matrix-js-sdk": "^41.5.0",
"mime": "^3.0.0", "mime": "^3.0.0",
"node-fetch": "^3.3.0", "node-fetch": "^3.3.0",
@@ -7314,6 +7317,23 @@
"optional": true, "optional": true,
"peer": 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": { "node_modules/compare-versions": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz", "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz",
@@ -7880,6 +7900,18 @@
"once": "^1.4.0" "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": { "node_modules/env-editor": {
"version": "0.4.2", "version": "0.4.2",
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
@@ -10350,6 +10382,12 @@
"optional": true, "optional": true,
"peer": 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": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -10379,6 +10417,12 @@
"optional": true, "optional": true,
"peer": 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": { "node_modules/lodash.throttle": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
@@ -10794,6 +10838,12 @@
"optional": true, "optional": true,
"peer": 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": { "node_modules/media-typer": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -11836,8 +11886,6 @@
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"optional": true,
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
@@ -20839,6 +20887,16 @@
"optional": true, "optional": true,
"peer": 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": { "compare-versions": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz", "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz",
@@ -21287,6 +21345,11 @@
"once": "^1.4.0" "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": { "env-editor": {
"version": "0.4.2", "version": "0.4.2",
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
@@ -23163,6 +23226,11 @@
"optional": true, "optional": true,
"peer": 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": { "locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -23186,6 +23254,11 @@
"optional": true, "optional": true,
"peer": 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": { "lodash.throttle": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
@@ -23524,6 +23597,11 @@
"optional": true, "optional": true,
"peer": 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": { "media-typer": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -24351,9 +24429,7 @@
"minimist": { "minimist": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="
"optional": true,
"peer": true
}, },
"minipass": { "minipass": {
"version": "3.1.6", "version": "3.1.6",
+5 -1
View File
@@ -4,12 +4,15 @@
"description": "Matrix chat server client for Node-RED", "description": "Matrix chat server client for Node-RED",
"dependencies": { "dependencies": {
"abort-controller": "^3.0.0", "abort-controller": "^3.0.0",
"commonmark": "^0.31.2",
"fake-indexeddb": "^6.2.5", "fake-indexeddb": "^6.2.5",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"fs-extra": "^11.1.0", "fs-extra": "^11.1.0",
"got": "^12.0.2", "got": "^12.0.2",
"image-size": "^1.0.2", "image-size": "^1.0.2",
"isomorphic-webcrypto": "^2.3.8", "isomorphic-webcrypto": "^2.3.8",
"linkifyjs": "^4.3.3",
"lodash.escape": "^4.0.1",
"matrix-js-sdk": "^41.5.0", "matrix-js-sdk": "^41.5.0",
"mime": "^3.0.0", "mime": "^3.0.0",
"node-fetch": "^3.3.0", "node-fetch": "^3.3.0",
@@ -53,7 +56,8 @@
"matrix-get-event": "src/matrix-get-event.js", "matrix-get-event": "src/matrix-get-event.js",
"matrix-event-relations": "src/matrix-event-relations.js", "matrix-event-relations": "src/matrix-event-relations.js",
"matrix-verification": "src/matrix-verification.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": { "engines": {
+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>
<li><code>msg.type</code> == '<strong>m.location</strong>' <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"> <dl class="message-properties">
<dt>msg.geo_uri <span class="property-type">string</span></dt> <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> </dl>
</li> </li>
</ul> </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) { module.exports = function(RED) {
function MatrixReceiveMessage(n) { function MatrixReceiveMessage(n) {
RED.nodes.createNode(this, n); RED.nodes.createNode(this, n);
@@ -146,11 +159,31 @@ module.exports = function(RED) {
setThumbnailUrls('thumbnail_file'); setThumbnailUrls('thumbnail_file');
break; break;
case 'm.location': case 'm.location': {
if (!node.acceptLocations) return; if (!node.acceptLocations) return;
msg.geo_uri = msg.content.geo_uri; msg.geo_uri = msg.content.geo_uri;
msg.payload = msg.content.body; 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; break;
}
case 'm.reaction': case 'm.reaction':
if (!node.acceptReactions) return; 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> </label>
<select id="node-input-messageFormat"> <select id="node-input-messageFormat">
<option value="">Default (plaintext)</option> <option value="">Default (plaintext)</option>
<option value="markdown">Markdown</option>
<option value="html">HTML</option> <option value="html">HTML</option>
<option value="msg.format">msg.format input</option> <option value="msg.format">msg.format input</option>
</select> </select>
@@ -140,7 +141,7 @@
<dt class="optional">msg.formatted_payload <dt class="optional">msg.formatted_payload
<span class="property-type">string</span> <span class="property-type">string</span>
</dt> </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 <dt class="optional">msg.type
<span class="property-type">string | null</span> <span class="property-type">string | null</span>
@@ -150,7 +151,35 @@
<dt class="optional">msg.format <dt class="optional">msg.format
<span class="property-type">string | null</span> <span class="property-type">string | null</span>
</dt> </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> </dl>
<h3>Outputs</h3> <h3>Outputs</h3>
+23 -1
View File
@@ -1,4 +1,5 @@
const sdkPromise = import("matrix-js-sdk"); const sdkPromise = import("matrix-js-sdk");
const { Markdown } = require("./matrix-markdown");
module.exports = function(RED) { module.exports = function(RED) {
function MatrixSendImage(n) { function MatrixSendImage(n) {
@@ -143,7 +144,28 @@ module.exports = function(RED) {
body: payload.toString() 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.format = "org.matrix.custom.html";
content.formatted_body = content.formatted_body =
(typeof msg.formatted_payload !== 'undefined' && msg.formatted_payload) (typeof msg.formatted_payload !== 'undefined' && msg.formatted_payload)
+2 -1
View File
@@ -293,7 +293,8 @@ module.exports = function(RED) {
msg.payload.url = file.content_uri; msg.payload.url = file.content_uri;
} }
msg.payload.msgtype = msgtype; msg.payload.msgtype = msgtype;
msg.payload.body = msg.body || msg.filename || ""; msg.payload.body = msg.body || filename || "";
msg.payload.filename = filename;
msg.payload.info = { msg.payload.info = {
"mimetype": contentType, "mimetype": contentType,
"size": getFileSize(bufferOrPath), "size": getFileSize(bufferOrPath),