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); +};