diff --git a/package.json b/package.json
index d005328..e4b9b62 100644
--- a/package.json
+++ b/package.json
@@ -56,7 +56,8 @@
"matrix-get-event": "src/matrix-get-event.js",
"matrix-event-relations": "src/matrix-event-relations.js",
"matrix-verification": "src/matrix-verification.js",
- "matrix-verification-action": "src/matrix-verification-action.js"
+ "matrix-verification-action": "src/matrix-verification-action.js",
+ "matrix-send-location": "src/matrix-send-location.js"
}
},
"engines": {
diff --git a/src/matrix-receive.html b/src/matrix-receive.html
index bfe4a50..6c9c8c2 100644
--- a/src/matrix-receive.html
+++ b/src/matrix-receive.html
@@ -357,9 +357,36 @@
msg.type == 'm.location'
+
+ The structured location fields are surfaced at the top level of
+ msg so the message can be wired straight into a
+ matrix-send-location node to resend the location
+ without any field translation in between.
+
- msg.geo_uri string
- - URI format of the geolocation
+ - The RFC 5870 geo URI from the event (e.g.
geo:48.85,2.35).
+
+ - msg.latitude number
+ - Latitude in decimal degrees, parsed from the geo URI.
+
+ - msg.longitude number
+ - Longitude in decimal degrees, parsed from the geo URI.
+
+ - msg.altitude number
+ - Metres above sea level, parsed from the geo URI. Only set when the geo URI includes an altitude component (
geo:lat,lng,alt).
+
+ - msg.description string
+ - The location's label (e.g. Eiffel Tower). Only set when the sender included one.
+
+ - msg.assetType string
+ "m.self" when the sender was sharing their own location, "m.pin" for a generic dropped pin. Defaults to "m.self" when the event does not carry an explicit asset type (per the Matrix spec).
+
+ - msg.timestamp number
+ - Milliseconds since the UNIX epoch when the location was correct (the event's
m.ts field). Only set when the sender included a timestamp.
+
+ - msg.payload string
+ - The event's
body — a human-readable text fallback for clients that cannot render the map snippet.
diff --git a/src/matrix-receive.js b/src/matrix-receive.js
index 45f1e76..028b7f1 100644
--- a/src/matrix-receive.js
+++ b/src/matrix-receive.js
@@ -1,3 +1,16 @@
+// Parse an RFC 5870 geo URI into {latitude, longitude, altitude?}.
+// Returns null if the URI is missing or malformed.
+function parseGeoUri(uri) {
+ if (typeof uri !== "string" || uri.indexOf("geo:") !== 0) return null;
+ // strip any ";u=..." / ";crs=..." parameters
+ const body = uri.slice(4).split(";")[0];
+ const parts = body.split(",").map(function(s) { return parseFloat(s.trim()); });
+ if (parts.length < 2 || !Number.isFinite(parts[0]) || !Number.isFinite(parts[1])) return null;
+ const result = { latitude: parts[0], longitude: parts[1] };
+ if (parts.length >= 3 && Number.isFinite(parts[2])) result.altitude = parts[2];
+ return result;
+}
+
module.exports = function(RED) {
function MatrixReceiveMessage(n) {
RED.nodes.createNode(this, n);
@@ -146,11 +159,31 @@ module.exports = function(RED) {
setThumbnailUrls('thumbnail_file');
break;
- case 'm.location':
+ case 'm.location': {
if (!node.acceptLocations) return;
msg.geo_uri = msg.content.geo_uri;
msg.payload = msg.content.body;
+ // Surface the structured location fields at the top level
+ // so a `matrix-send-location` node wired straight to this
+ // output resends the same location. Both the stable
+ // (m.location / m.asset / m.ts) and the MSC3488-prefixed
+ // namespaces are checked, since Element currently emits
+ // the prefixed form even though the spec is stable.
+ const loc = msg.content["m.location"] || msg.content["org.matrix.msc3488.location"] || {};
+ const asset = msg.content["m.asset"] || msg.content["org.matrix.msc3488.asset"] || {};
+ let ts = msg.content["m.ts"];
+ if (typeof ts !== "number") ts = msg.content["org.matrix.msc3488.ts"];
+ const coords = parseGeoUri(loc.uri || msg.geo_uri);
+ if (coords) {
+ msg.latitude = coords.latitude;
+ msg.longitude = coords.longitude;
+ if (coords.altitude !== undefined) msg.altitude = coords.altitude;
+ }
+ if (loc.description) msg.description = loc.description;
+ msg.assetType = asset.type || "m.self";
+ if (typeof ts === "number") msg.timestamp = ts;
break;
+ }
case 'm.reaction':
if (!node.acceptReactions) return;
diff --git a/src/matrix-send-location.html b/src/matrix-send-location.html
new file mode 100644
index 0000000..78b9034
--- /dev/null
+++ b/src/matrix-send-location.html
@@ -0,0 +1,263 @@
+
+
+
+
+
diff --git a/src/matrix-send-location.js b/src/matrix-send-location.js
new file mode 100644
index 0000000..3ca4530
--- /dev/null
+++ b/src/matrix-send-location.js
@@ -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:,[,] 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);
+};