diff --git a/app/bot/src/main.rs b/app/bot/src/main.rs index f0a5338..81b7619 100644 --- a/app/bot/src/main.rs +++ b/app/bot/src/main.rs @@ -1,7 +1,7 @@ use dptree::deps; use lambda_runtime::{service_fn, Error as LambdaError, LambdaEvent}; use serde_json::{json, Value}; -use station::fuzzy::get_station; +use station::search::get_station; use teloxide::{ prelude::*, types::{LinkPreviewOptions, Me, ParseMode}, @@ -71,28 +71,21 @@ async fn lambda_handler(event: LambdaEvent) -> Result let shared_config = aws_config::load_defaults(BehaviorVersion::latest()).await; let dynamodb_client = DynamoDbClient::new(&shared_config); let message = msg.text().unwrap(); - let stations = station::stations(); - let closest_station = stations.iter().min_by_key(|&s| { - edit_distance::edit_distance( - &message.to_lowercase(), - &s.replace(" ", "").to_lowercase(), - ) - }); let text = match get_station( &dynamodb_client, - closest_station.unwrap().to_string(), + message.to_string(), "Stazioni", ) .await { - Ok(item) => { + Ok(Some(item)) => { if item.nomestaz != message { format!("{}\nSe non รจ la stazione corretta prova ad affinare la ricerca.", item.create_station_message()) }else { item.create_station_message().to_string() } } - Err(_) => "Nessuna stazione trovata con la parola di ricerca. \n + Err(_) | Ok(None) => "Nessuna stazione trovata con la parola di ricerca. \n Inserisci esattamente il nome che vedi dalla pagina https://allertameteo.regione.emilia-romagna.it/livello-idrometrico \n Ad esempio 'Cesena', 'Lavino di Sopra' o 'S. Carlo'. \n Se non sai quale cercare prova con /stazioni".to_string(), diff --git a/app/bot/src/station/fuzzy.rs b/app/bot/src/station/fuzzy.rs deleted file mode 100644 index 3d8926e..0000000 --- a/app/bot/src/station/fuzzy.rs +++ /dev/null @@ -1,118 +0,0 @@ -use anyhow::{anyhow, Result}; -use aws_sdk_dynamodb::{types::AttributeValue, Client as DynamoDbClient}; -use std::collections::HashMap; - -use super::{Stazione, UNKNOWN_VALUE}; - -pub async fn get_station( - client: &DynamoDbClient, - station_name: String, - table_name: &str, -) -> Result { - let result = client - .get_item() - .table_name(table_name) - .key("nomestaz", AttributeValue::S(station_name.clone())) - .send() - .await?; - - match result.item { - Some(item) => { - // Parse each field with proper error handling - let idstazione = parse_string_field(&item, "idstazione")?; - let timestamp = parse_number_field::(&item, "timestamp")?; - let lon = parse_string_field(&item, "lon")?; - let lat = parse_string_field(&item, "lat")?; - let ordinamento = parse_number_field::(&item, "ordinamento")?; - let nomestaz = parse_string_field(&item, "nomestaz")?; - let soglia1 = parse_number_field::(&item, "soglia1")?; - let soglia2 = parse_number_field::(&item, "soglia2")?; - let soglia3 = parse_number_field::(&item, "soglia3")?; - let value = parse_optional_number_field(&item, "value")?.unwrap_or(UNKNOWN_VALUE); - - Ok(Stazione { - timestamp, - idstazione, - ordinamento, - nomestaz, - lon, - lat, - soglia1, - soglia2, - soglia3, - value, - }) - } - None => Err(anyhow!("Station '{}' not found", station_name)), - } -} - -fn parse_string_field(item: &HashMap, field: &str) -> Result { - match item.get(field) { - Some(AttributeValue::S(s)) => Ok(s.clone()), - Some(AttributeValue::Ss(ss)) => Ok(ss.join(",")), // If the field is a string set - _ => Err(anyhow!("Missing or invalid '{}' field", field)), - } -} - -fn parse_number_field( - item: &HashMap, - field: &str, -) -> Result -where - ::Err: std::fmt::Display, -{ - match item.get(field) { - Some(AttributeValue::N(n)) => n.parse::().map_err(|e| { - anyhow!( - "Failed to parse '{}' field with value '{}' as number: {}", - field, - n, - e - ) - }), - Some(AttributeValue::S(s)) => s.parse::().map_err(|e| { - anyhow!( - "Failed to parse '{}' field with value '{}' as number: {}", - field, - s, - e - ) - }), - _ => Err(anyhow!("Missing or invalid '{}' field", field)), - } -} - -fn parse_optional_number_field( - item: &HashMap, - field: &str, -) -> Result> -where - ::Err: std::fmt::Display, -{ - match item.get(field) { - Some(AttributeValue::N(n)) => { - if let Ok(value) = n.parse::() { - Ok(Some(value)) - } else { - Err(anyhow!( - "Failed to parse '{}' field with value '{}' as number", - field, - n - )) - } - } - Some(AttributeValue::S(s)) => { - if let Ok(value) = s.parse::() { - Ok(Some(value)) - } else { - Err(anyhow!( - "Failed to parse '{}' field with value '{}' as number", - field, - s - )) - } - } - _ => Err(anyhow!("Invalid type for '{}' field", field)), - } -} diff --git a/app/bot/src/station/mod.rs b/app/bot/src/station/mod.rs index b88f3a9..1c106b0 100644 --- a/app/bot/src/station/mod.rs +++ b/app/bot/src/station/mod.rs @@ -1,4 +1,4 @@ -pub mod fuzzy; +pub mod search; use chrono::{DateTime, TimeZone}; use chrono_tz::Europe::Rome; diff --git a/app/bot/src/station/search.rs b/app/bot/src/station/search.rs new file mode 100644 index 0000000..6ac38c2 --- /dev/null +++ b/app/bot/src/station/search.rs @@ -0,0 +1,178 @@ +use anyhow::{anyhow, Result}; +use aws_sdk_dynamodb::{types::AttributeValue, Client as DynamoDbClient}; +use std::collections::HashMap; + +use super::{stations, Stazione, UNKNOWN_VALUE}; + +fn fuzzy_search(search: &str) -> Option { + let stations = stations(); + let closest_match = stations + .iter() + .map(|s: &String| { + ( + s, + edit_distance::edit_distance( + &search.to_lowercase(), + &s.replace(" ", "").to_lowercase(), + ), + ) + }) + .filter(|(_, score)| *score < 4) + .min_by_key(|(_, score)| *score) + .map(|(station, _)| station.clone()); // Map to String and clone the station name + + closest_match +} + +pub async fn get_station( + client: &DynamoDbClient, + station_name: String, + table_name: &str, +) -> Result> { + if let Some(closest_match) = fuzzy_search(&station_name) { + let result = client + .get_item() + .table_name(table_name) + .key("nomestaz", AttributeValue::S(closest_match.clone())) + .send() + .await?; + + match result.item { + Some(item) => { + let idstazione = parse_string_field(&item, "idstazione")?; + let timestamp = parse_number_field::(&item, "timestamp")?; + let lon = parse_string_field(&item, "lon")?; + let lat = parse_string_field(&item, "lat")?; + let ordinamento = parse_number_field::(&item, "ordinamento")?; + let nomestaz = parse_string_field(&item, "nomestaz")?; + let soglia1 = parse_number_field::(&item, "soglia1")?; + let soglia2 = parse_number_field::(&item, "soglia2")?; + let soglia3 = parse_number_field::(&item, "soglia3")?; + let value = parse_optional_number_field(&item, "value")?.unwrap_or(UNKNOWN_VALUE); + + Ok(Some(Stazione { + timestamp, + idstazione, + ordinamento, + nomestaz, + lon, + lat, + soglia1, + soglia2, + soglia3, + value, + })) + } + None => Err(anyhow!("Station '{}' not found", closest_match)), + } + } else { + Err(anyhow!("'{}' did not match any know station", station_name)) + } +} + +fn parse_string_field(item: &HashMap, field: &str) -> Result { + match item.get(field) { + Some(AttributeValue::S(s)) => Ok(s.clone()), + Some(AttributeValue::Ss(ss)) => Ok(ss.join(",")), // If the field is a string set + _ => Err(anyhow!("Missing or invalid '{}' field", field)), + } +} + +fn parse_number_field( + item: &HashMap, + field: &str, +) -> Result +where + ::Err: std::fmt::Display, +{ + match item.get(field) { + Some(AttributeValue::N(n)) => n.parse::().map_err(|e| { + anyhow!( + "Failed to parse '{}' field with value '{}' as number: {}", + field, + n, + e + ) + }), + Some(AttributeValue::S(s)) => s.parse::().map_err(|e| { + anyhow!( + "Failed to parse '{}' field with value '{}' as number: {}", + field, + s, + e + ) + }), + _ => Err(anyhow!("Missing or invalid '{}' field", field)), + } +} + +fn parse_optional_number_field( + item: &HashMap, + field: &str, +) -> Result> +where + ::Err: std::fmt::Display, +{ + match item.get(field) { + Some(AttributeValue::N(n)) => { + if let Ok(value) = n.parse::() { + Ok(Some(value)) + } else { + Err(anyhow!( + "Failed to parse '{}' field with value '{}' as number", + field, + n + )) + } + } + Some(AttributeValue::S(s)) => { + if let Ok(value) = s.parse::() { + Ok(Some(value)) + } else { + Err(anyhow!( + "Failed to parse '{}' field with value '{}' as number", + field, + s + )) + } + } + _ => Err(anyhow!("Invalid type for '{}' field", field)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fuzzy_search_cesena_yields_cesena_station() { + let message = "cesena".to_string(); + let expected = Some("Cesena".to_string()); + + assert_eq!(fuzzy_search(&message), expected); + } + + #[test] + fn fuzzy_search_scarlo_yields_scarlo_station() { + let message = "scarlo".to_string(); + let expected = Some("S. Carlo".to_string()); + + assert_eq!(fuzzy_search(&message), expected); + } + + #[test] + fn fuzzy_search_nonexisting_yields_nonexisting_station() { + let message = "thisdoesnotexists".to_string(); + let expected = None; + + assert_eq!(fuzzy_search(&message), expected); + } + + #[test] + fn fuzzy_search_ecsena_yields_cesena_station() { + let message = "ecsena".to_string(); + let expected = Some("Cesena".to_string()); + + assert_eq!(fuzzy_search(&message), expected); + } +}