mirror of
https://github.com/Skylar-Tech/node-red-contrib-matrix-chat.git
synced 2026-05-23 15:43:33 -06:00
Compare commits
4 Commits
v1.0.0-beta.1
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| af1067a99b | |||
| 390a5b264e | |||
| 9dc4362819 | |||
| 1801b49fae |
@@ -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).
|
||||||
|
|
||||||
|
|||||||
Generated
+81
-5
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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
@@ -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 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> — a human-readable text fallback for clients that cannot render the map snippet.</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+34
-1
@@ -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;
|
||||||
|
|||||||
@@ -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 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 — 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> — 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 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 — 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.<configured></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> — 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 — <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>
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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 — 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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user