mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-07-06 01:42:11 +02:00
2bd69415c1
* matrix: create potato-matrix-bridge
* matrix: add unit tests
* matrix: address review comments
* ci: condition github actions to only run on paths affected...
* Add comprehensive unit tests for config, matrix, potatomesh, and main modules
* Revert "Add comprehensive unit tests for config, matrix, potatomesh, and main modules"
This reverts commit 212522b4a2.
* matrix: add unit tests
* matrix: add unit tests
* matrix: add unit tests
322 lines
9.9 KiB
Rust
322 lines
9.9 KiB
Rust
mod config;
|
|
mod matrix;
|
|
mod potatomesh;
|
|
|
|
use std::{fs, path::Path};
|
|
|
|
use anyhow::Result;
|
|
use tokio::time::{sleep, Duration};
|
|
use tracing::{error, info};
|
|
|
|
use crate::config::Config;
|
|
use crate::matrix::MatrixAppserviceClient;
|
|
use crate::potatomesh::{PotatoClient, PotatoMessage};
|
|
|
|
#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
|
|
pub struct BridgeState {
|
|
last_message_id: Option<u64>,
|
|
}
|
|
|
|
impl BridgeState {
|
|
fn load(path: &str) -> Result<Self> {
|
|
if !Path::new(path).exists() {
|
|
return Ok(Self::default());
|
|
}
|
|
let data = fs::read_to_string(path)?;
|
|
let s: Self = serde_json::from_str(&data)?;
|
|
Ok(s)
|
|
}
|
|
|
|
fn save(&self, path: &str) -> Result<()> {
|
|
let data = serde_json::to_string_pretty(self)?;
|
|
fs::write(path, data)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn should_forward(&self, msg: &PotatoMessage) -> bool {
|
|
match self.last_message_id {
|
|
None => true,
|
|
Some(last) => msg.id > last,
|
|
}
|
|
}
|
|
|
|
fn update_with(&mut self, msg: &PotatoMessage) {
|
|
self.last_message_id = Some(match self.last_message_id {
|
|
None => msg.id,
|
|
Some(last) => last.max(msg.id),
|
|
});
|
|
}
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
// Logging: RUST_LOG=info,bridge=debug,reqwest=warn ...
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::from_default_env()
|
|
.add_directive("potatomesh_matrix_bridge=info".parse().unwrap_or_default())
|
|
.add_directive("reqwest=warn".parse().unwrap_or_default()),
|
|
)
|
|
.init();
|
|
|
|
let cfg = Config::from_default_path()?;
|
|
info!("Loaded config: {:?}", cfg);
|
|
|
|
let http = reqwest::Client::builder().build()?;
|
|
let potato = PotatoClient::new(http.clone(), cfg.potatomesh.clone());
|
|
let matrix = MatrixAppserviceClient::new(http.clone(), cfg.matrix.clone());
|
|
|
|
let state_path = &cfg.state.state_file;
|
|
let mut state = BridgeState::load(state_path)?;
|
|
info!("Loaded state: {:?}", state);
|
|
|
|
let poll_interval = Duration::from_secs(cfg.potatomesh.poll_interval_secs);
|
|
|
|
loop {
|
|
match potato.fetch_messages().await {
|
|
Ok(mut msgs) => {
|
|
// sort by id ascending so we process in order
|
|
msgs.sort_by_key(|m| m.id);
|
|
|
|
for msg in msgs {
|
|
if !state.should_forward(&msg) {
|
|
continue;
|
|
}
|
|
|
|
// Filter to the ports you care about
|
|
if msg.portnum != "TEXT_MESSAGE_APP" {
|
|
state.update_with(&msg);
|
|
continue;
|
|
}
|
|
|
|
if let Err(e) = handle_message(&potato, &matrix, &mut state, &msg).await {
|
|
error!("Error handling message {}: {:?}", msg.id, e);
|
|
}
|
|
|
|
// persist after each processed message
|
|
if let Err(e) = state.save(state_path) {
|
|
error!("Error saving state: {:?}", e);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
error!("Error fetching PotatoMesh messages: {:?}", e);
|
|
}
|
|
}
|
|
|
|
sleep(poll_interval).await;
|
|
}
|
|
}
|
|
|
|
async fn handle_message(
|
|
potato: &PotatoClient,
|
|
matrix: &MatrixAppserviceClient,
|
|
state: &mut BridgeState,
|
|
msg: &PotatoMessage,
|
|
) -> Result<()> {
|
|
let node = potato.get_node(&msg.node_id).await?;
|
|
let localpart = MatrixAppserviceClient::localpart_from_node_id(&msg.node_id);
|
|
let user_id = matrix.user_id(&localpart);
|
|
|
|
// Ensure puppet exists & has display name
|
|
matrix.ensure_user_registered(&localpart).await?;
|
|
matrix.set_display_name(&user_id, &node.long_name).await?;
|
|
|
|
// Format the bridged message
|
|
let short = node
|
|
.short_name
|
|
.clone()
|
|
.unwrap_or_else(|| node.long_name.clone());
|
|
|
|
let body = format!(
|
|
"[{short}] {text}\n({from_id} → {to_id}, RSSI {rssi} dB, SNR {snr} dB, {chan}/{preset})",
|
|
short = short,
|
|
text = msg.text,
|
|
from_id = msg.from_id,
|
|
to_id = msg.to_id,
|
|
rssi = msg.rssi,
|
|
snr = msg.snr,
|
|
chan = msg.channel_name,
|
|
preset = msg.modem_preset,
|
|
);
|
|
|
|
matrix.send_text_message_as(&user_id, &body).await?;
|
|
|
|
state.update_with(msg);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::{MatrixConfig, PotatomeshConfig};
|
|
use crate::matrix::MatrixAppserviceClient;
|
|
use crate::potatomesh::PotatoClient;
|
|
|
|
fn sample_msg(id: u64) -> PotatoMessage {
|
|
PotatoMessage {
|
|
id,
|
|
rx_time: 0,
|
|
rx_iso: "2025-11-27T00:00:00Z".to_string(),
|
|
from_id: "!abcd1234".to_string(),
|
|
to_id: "^all".to_string(),
|
|
channel: 1,
|
|
portnum: "TEXT_MESSAGE_APP".to_string(),
|
|
text: "Ping".to_string(),
|
|
rssi: -100,
|
|
hop_limit: 1,
|
|
lora_freq: 868,
|
|
modem_preset: "MediumFast".to_string(),
|
|
channel_name: "TEST".to_string(),
|
|
snr: 0.0,
|
|
reply_id: None,
|
|
node_id: "!abcd1234".to_string(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn bridge_state_initially_forwards_all() {
|
|
let state = BridgeState::default();
|
|
let msg = sample_msg(42);
|
|
|
|
assert!(state.should_forward(&msg));
|
|
}
|
|
|
|
#[test]
|
|
fn bridge_state_tracks_highest_id_and_skips_older() {
|
|
let mut state = BridgeState::default();
|
|
let m1 = sample_msg(10);
|
|
let m2 = sample_msg(20);
|
|
let m3 = sample_msg(15);
|
|
|
|
// First message, should forward
|
|
assert!(state.should_forward(&m1));
|
|
state.update_with(&m1);
|
|
assert_eq!(state.last_message_id, Some(10));
|
|
|
|
// Second message, higher id, should forward
|
|
assert!(state.should_forward(&m2));
|
|
state.update_with(&m2);
|
|
assert_eq!(state.last_message_id, Some(20));
|
|
|
|
// Third message, lower than last, should NOT forward
|
|
assert!(!state.should_forward(&m3));
|
|
// state remains unchanged
|
|
assert_eq!(state.last_message_id, Some(20));
|
|
}
|
|
|
|
#[test]
|
|
fn bridge_state_update_is_monotonic() {
|
|
let mut state = BridgeState {
|
|
last_message_id: Some(50),
|
|
};
|
|
let m = sample_msg(40);
|
|
|
|
state.update_with(&m); // id is lower than current
|
|
// last_message_id must stay at 50
|
|
assert_eq!(state.last_message_id, Some(50));
|
|
}
|
|
|
|
#[test]
|
|
fn bridge_state_load_save_roundtrip() {
|
|
let tmp_dir = tempfile::tempdir().unwrap();
|
|
let file_path = tmp_dir.path().join("state.json");
|
|
let path_str = file_path.to_str().unwrap();
|
|
|
|
let state = BridgeState {
|
|
last_message_id: Some(12345),
|
|
};
|
|
state.save(path_str).unwrap();
|
|
|
|
let loaded_state = BridgeState::load(path_str).unwrap();
|
|
assert_eq!(loaded_state.last_message_id, Some(12345));
|
|
}
|
|
|
|
#[test]
|
|
fn bridge_state_load_nonexistent() {
|
|
let tmp_dir = tempfile::tempdir().unwrap();
|
|
let file_path = tmp_dir.path().join("nonexistent.json");
|
|
let path_str = file_path.to_str().unwrap();
|
|
|
|
let state = BridgeState::load(path_str).unwrap();
|
|
assert_eq!(state.last_message_id, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_handle_message() {
|
|
let mut server = mockito::Server::new_async().await;
|
|
|
|
let potatomesh_cfg = PotatomeshConfig {
|
|
base_url: server.url(),
|
|
poll_interval_secs: 1,
|
|
};
|
|
let matrix_cfg = MatrixConfig {
|
|
homeserver: server.url(),
|
|
as_token: "AS_TOKEN".to_string(),
|
|
server_name: "example.org".to_string(),
|
|
room_id: "!roomid:example.org".to_string(),
|
|
};
|
|
|
|
let node_id = "abcd1234";
|
|
let user_id = format!("@{}:{}", node_id, matrix_cfg.server_name);
|
|
let encoded_user = urlencoding::encode(&user_id);
|
|
|
|
let mock_get_node = server
|
|
.mock("GET", "/nodes/abcd1234")
|
|
.with_status(200)
|
|
.with_header("content-type", "application/json")
|
|
.with_body(r#"{"node_id": "!abcd1234", "long_name": "Test Node", "short_name": "TN"}"#)
|
|
.create();
|
|
|
|
let mock_register = server
|
|
.mock("POST", "/_matrix/client/v3/register")
|
|
.match_query("kind=user&access_token=AS_TOKEN")
|
|
.with_status(200)
|
|
.create();
|
|
|
|
let mock_display_name = server
|
|
.mock(
|
|
"PUT",
|
|
format!("/_matrix/client/v3/profile/{}/displayname", encoded_user).as_str(),
|
|
)
|
|
.match_query(format!("user_id={}&access_token=AS_TOKEN", encoded_user).as_str())
|
|
.with_status(200)
|
|
.create();
|
|
|
|
let http_client = reqwest::Client::new();
|
|
let matrix_client = MatrixAppserviceClient::new(http_client.clone(), matrix_cfg);
|
|
let room_id = &matrix_client.cfg.room_id;
|
|
let encoded_room = urlencoding::encode(room_id);
|
|
let txn_id = matrix_client
|
|
.txn_counter
|
|
.load(std::sync::atomic::Ordering::SeqCst);
|
|
|
|
let mock_send = server
|
|
.mock(
|
|
"PUT",
|
|
format!(
|
|
"/_matrix/client/v3/rooms/{}/send/m.room.message/{}",
|
|
encoded_room, txn_id
|
|
)
|
|
.as_str(),
|
|
)
|
|
.match_query(format!("user_id={}&access_token=AS_TOKEN", encoded_user).as_str())
|
|
.with_status(200)
|
|
.create();
|
|
|
|
let potato_client = PotatoClient::new(http_client.clone(), potatomesh_cfg);
|
|
let mut state = BridgeState::default();
|
|
let msg = sample_msg(100);
|
|
|
|
let result = handle_message(&potato_client, &matrix_client, &mut state, &msg).await;
|
|
|
|
assert!(result.is_ok());
|
|
mock_get_node.assert();
|
|
mock_register.assert();
|
|
mock_display_name.assert();
|
|
mock_send.assert();
|
|
|
|
assert_eq!(state.last_message_id, Some(100));
|
|
}
|
|
}
|