diff --git a/matrix/Config.toml b/matrix/Config.toml index c590973..8dbf907 100644 --- a/matrix/Config.toml +++ b/matrix/Config.toml @@ -1,6 +1,6 @@ [potatomesh] -# Base URL without trailing slash -base_url = "https://potatomesh.net/api" +# Base domain (with or without trailing slash) +base_url = "https://potatomesh.net/" # Poll interval in seconds poll_interval_secs = 60 diff --git a/matrix/README.md b/matrix/README.md index 22386f0..1e00fad 100644 --- a/matrix/README.md +++ b/matrix/README.md @@ -4,7 +4,7 @@ A small Rust daemon that bridges **PotatoMesh** LoRa messages into a **Matrix** For each PotatoMesh node, the bridge creates (or uses) a **Matrix puppet user**: -- Matrix localpart: the hex node id (without `!`), e.g. `!67fc83cb` → `@67fc83cb:example.org` +- Matrix localpart: `potato_` + the hex node id (without `!`), e.g. `!67fc83cb` → `@potato_67fc83cb:example.org` - Matrix display name: the node’s `long_name` from the PotatoMesh API Messages from PotatoMesh are periodically fetched and forwarded to a single Matrix room as those puppet users. @@ -13,10 +13,10 @@ Messages from PotatoMesh are periodically fetched and forwarded to a single Matr ## Features -- Polls `https://potatomesh.net/api/messages` (or any configured base URL) -- Looks up node metadata via `GET /nodes/{hex}` and caches it +- Polls `https://potatomesh.net/api/messages` (deriving `/api` from the configured base domain) +- Looks up node metadata via `GET /api/nodes/{hex}` and caches it - One Matrix user per node: - - username: hex node id + - username: `potato_{hex node id}` - display name: `long_name` - Forwards `TEXT_MESSAGE_APP` messages into a single Matrix room - Persists last-seen message ID to avoid duplicates across restarts @@ -26,12 +26,12 @@ Messages from PotatoMesh are periodically fetched and forwarded to a single Matr ## Architecture Overview - **PotatoMesh side** - - `GET /messages` returns an array of messages - - `GET /nodes/{hex}` returns node metadata (including `long_name`) + - `GET /api/messages` returns an array of messages + - `GET /api/nodes/{hex}` returns node metadata (including `long_name`) - **Matrix side** - Uses the Matrix Client-Server API with an **appservice access token** - - Impersonates puppet users via `user_id=@{hex}:{server_name}&access_token={as_token}` + - Impersonates puppet users via `user_id=@potato_{hex}:{server_name}&access_token={as_token}` - Sends `m.room.message` events into a configured room This is **not** a full appservice framework; it just speaks the minimal HTTP needed. @@ -43,11 +43,11 @@ This is **not** a full appservice framework; it just speaks the minimal HTTP nee - Rust (stable) and `cargo` - A Matrix homeserver you control (e.g. Synapse) - An **application service registration** on your homeserver that: - - Whitelists the puppet user namespace (e.g. `@[0-9a-f]{8}:example.org`) + - Whitelists the puppet user namespace (e.g. `@potato_[0-9a-f]{8}:example.org`) - Provides an `as_token` the bridge can use - Network access from the bridge host to: - - `https://potatomesh.net/api` (or your configured PotatoMesh API) + - `https://potatomesh.net/` (bridge appends `/api`) - Your Matrix homeserver (`https://matrix.example.org`) --- @@ -60,8 +60,8 @@ Example: ```toml [potatomesh] -# Base URL without trailing slash -base_url = "https://potatomesh.net/api" +# Base domain (bridge will call {base_url}/api) +base_url = "https://potatomesh.net/" # Poll interval in seconds poll_interval_secs = 10 @@ -84,7 +84,7 @@ state_file = "bridge_state.json" The bridge assumes: -* Messages: `GET {base_url}/messages` → JSON array, for example: +* Messages: `GET {base_url}/api/messages` → JSON array, for example: ```json [ @@ -108,7 +108,7 @@ The bridge assumes: ] ``` -* Nodes: `GET {base_url}/nodes/{hex}` → JSON, for example: +* Nodes: `GET {base_url}/api/nodes/{hex}` → JSON, for example: ```json { @@ -122,7 +122,7 @@ The bridge assumes: } ``` -Node hex ID is derived from `node_id` by stripping the leading `!` and using the remainder as the Matrix localpart. +Node hex ID is derived from `node_id` by stripping the leading `!` and using the remainder inside the puppet localpart prefix (`potato_{hex}`). --- @@ -142,7 +142,7 @@ rate_limited: false namespaces: users: - exclusive: true - regex: "@[0-9a-f]{8}:example.org" + regex: "@potato_[0-9a-f]{8}:example.org" ``` For this bridge, only the `as_token` and `namespaces.users` actually matter. The bridge does not accept inbound events; it only uses the `as_token` to call the homeserver. @@ -193,7 +193,7 @@ The bridge will: 3. For each new `TEXT_MESSAGE_APP`: * Fetch node info. - * Ensure puppet is registered (`@{hex}:{server_name}`). + * Ensure puppet is registered (`@potato_{hex}:{server_name}`). * Set puppet display name to `long_name`. * Send a formatted text message into `room_id` as that puppet. * Update and persist `bridge_state.json`. diff --git a/matrix/src/config.rs b/matrix/src/config.rs index 48e214b..21c2118 100644 --- a/matrix/src/config.rs +++ b/matrix/src/config.rs @@ -67,7 +67,7 @@ mod tests { fn parse_minimal_config_from_toml_str() { let toml_str = r#" [potatomesh] - base_url = "https://potatomesh.net/api" + base_url = "https://potatomesh.net/" poll_interval_secs = 10 [matrix] @@ -81,7 +81,7 @@ mod tests { "#; let cfg: Config = toml::from_str(toml_str).expect("toml should parse"); - assert_eq!(cfg.potatomesh.base_url, "https://potatomesh.net/api"); + assert_eq!(cfg.potatomesh.base_url, "https://potatomesh.net/"); assert_eq!(cfg.potatomesh.poll_interval_secs, 10); assert_eq!(cfg.matrix.homeserver, "https://matrix.example.org"); @@ -102,7 +102,7 @@ mod tests { fn load_from_file_valid_file() { let toml_str = r#" [potatomesh] - base_url = "https://potatomesh.net/api" + base_url = "https://potatomesh.net/" poll_interval_secs = 10 [matrix] @@ -134,7 +134,7 @@ mod tests { fn from_default_path_found() { let toml_str = r#" [potatomesh] - base_url = "https://potatomesh.net/api" + base_url = "https://potatomesh.net/" poll_interval_secs = 10 [matrix] diff --git a/matrix/src/main.rs b/matrix/src/main.rs index 8b96787..28bf551 100644 --- a/matrix/src/main.rs +++ b/matrix/src/main.rs @@ -272,11 +272,11 @@ mod tests { }; let node_id = "abcd1234"; - let user_id = format!("@{}:{}", node_id, matrix_cfg.server_name); + let user_id = format!("@potato_{}:{}", node_id, matrix_cfg.server_name); let encoded_user = urlencoding::encode(&user_id); let mock_get_node = server - .mock("GET", "/nodes/abcd1234") + .mock("GET", "/api/nodes/abcd1234") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"node_id": "!abcd1234", "long_name": "Test Node", "short_name": "TN"}"#) diff --git a/matrix/src/matrix.rs b/matrix/src/matrix.rs index ff068e4..5b84d2f 100644 --- a/matrix/src/matrix.rs +++ b/matrix/src/matrix.rs @@ -41,9 +41,9 @@ impl MatrixAppserviceClient { } } - /// Convert a node_id like "!deadbeef" into Matrix localpart "deadbeef". + /// Convert a node_id like "!deadbeef" into Matrix localpart "potato_deadbeef". pub fn localpart_from_node_id(node_id: &str) -> String { - node_id.trim_start_matches('!').to_string() + format!("potato_{}", node_id.trim_start_matches('!')) } /// Build a full Matrix user_id from localpart. @@ -189,11 +189,11 @@ mod tests { fn localpart_strips_bang_correctly() { assert_eq!( MatrixAppserviceClient::localpart_from_node_id("!deadbeef"), - "deadbeef" + "potato_deadbeef" ); assert_eq!( MatrixAppserviceClient::localpart_from_node_id("cafebabe"), - "cafebabe" + "potato_cafebabe" ); } @@ -202,8 +202,8 @@ mod tests { let http = reqwest::Client::builder().build().unwrap(); let client = MatrixAppserviceClient::new(http, dummy_cfg()); - let uid = client.user_id("deadbeef"); - assert_eq!(uid, "@deadbeef:example.org"); + let uid = client.user_id("potato_deadbeef"); + assert_eq!(uid, "@potato_deadbeef:example.org"); } #[test] diff --git a/matrix/src/potatomesh.rs b/matrix/src/potatomesh.rs index 041f09e..93bd431 100644 --- a/matrix/src/potatomesh.rs +++ b/matrix/src/potatomesh.rs @@ -81,13 +81,23 @@ impl PotatoClient { } } + /// Build the API root; accept either a bare domain or one already ending in `/api`. + fn api_base(&self) -> String { + let trimmed = self.cfg.base_url.trim_end_matches('/'); + if trimmed.ends_with("/api") { + trimmed.to_string() + } else { + format!("{}/api", trimmed) + } + } + fn messages_url(&self) -> String { - format!("{}/messages", self.cfg.base_url) + format!("{}/messages", self.api_base()) } fn node_url(&self, hex_id: &str) -> String { // e.g. https://potatomesh.net/api/nodes/67fc83cb - format!("{}/nodes/{}", self.cfg.base_url, hex_id) + format!("{}/nodes/{}", self.api_base(), hex_id) } pub async fn fetch_messages(&self) -> anyhow::Result> { @@ -220,7 +230,29 @@ mod tests { poll_interval_secs: 60, }; let client = PotatoClient::new(http_client, config); - assert_eq!(client.messages_url(), "http://localhost:8080/messages"); + assert_eq!(client.messages_url(), "http://localhost:8080/api/messages"); + } + + #[test] + fn test_messages_url_with_trailing_slash() { + let http_client = reqwest::Client::new(); + let config = PotatomeshConfig { + base_url: "http://localhost:8080/".to_string(), + poll_interval_secs: 60, + }; + let client = PotatoClient::new(http_client, config); + assert_eq!(client.messages_url(), "http://localhost:8080/api/messages"); + } + + #[test] + fn test_messages_url_with_existing_api_suffix() { + let http_client = reqwest::Client::new(); + let config = PotatomeshConfig { + base_url: "http://localhost:8080/api/".to_string(), + poll_interval_secs: 60, + }; + let client = PotatoClient::new(http_client, config); + assert_eq!(client.messages_url(), "http://localhost:8080/api/messages"); } #[test] @@ -233,7 +265,7 @@ mod tests { let client = PotatoClient::new(http_client, config); assert_eq!( client.node_url("!1234"), - "http://localhost:8080/nodes/!1234" + "http://localhost:8080/api/nodes/!1234" ); } @@ -241,7 +273,7 @@ mod tests { async fn test_fetch_messages_success() { let mut server = mockito::Server::new_async().await; let mock = server - .mock("GET", "/messages") + .mock("GET", "/api/messages") .with_status(200) .with_header("content-type", "application/json") .with_body( @@ -277,7 +309,10 @@ mod tests { #[tokio::test] async fn test_fetch_messages_error() { let mut server = mockito::Server::new_async().await; - let mock = server.mock("GET", "/messages").with_status(500).create(); + let mock = server + .mock("GET", "/api/messages") + .with_status(500) + .create(); let http_client = reqwest::Client::new(); let config = PotatomeshConfig { @@ -327,7 +362,7 @@ mod tests { async fn test_get_node_cache_miss() { let mut server = mockito::Server::new_async().await; let mock = server - .mock("GET", "/nodes/1234") + .mock("GET", "/api/nodes/1234") .with_status(200) .with_header("content-type", "application/json") .with_body( @@ -362,7 +397,10 @@ mod tests { #[tokio::test] async fn test_get_node_error() { let mut server = mockito::Server::new_async().await; - let mock = server.mock("GET", "/nodes/1234").with_status(500).create(); + let mock = server + .mock("GET", "/api/nodes/1234") + .with_status(500) + .create(); let http_client = reqwest::Client::new(); let config = PotatomeshConfig {