matrix: omit the api part in base url (#554)

* matrix: omit the api part in base url

* matrix: address review comments
This commit is contained in:
l5y
2025-12-15 22:04:01 +01:00
committed by GitHub
parent fec649a159
commit 8811f71e53
6 changed files with 76 additions and 38 deletions

View File

@@ -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

View File

@@ -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 nodes `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`.

View File

@@ -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]

View File

@@ -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"}"#)

View File

@@ -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]

View File

@@ -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<Vec<PotatoMessage>> {
@@ -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 {