botserver/src/basic/keywords/weather.rs
Rodrigo Rodriguez (Pragmatismo) 4424fcf548 Fix compiler warnings and improve code consistency
- Remove unused imports and comment them for potential future use
- Add missing .send() to HTTP request chain
- Fix integer type suffixes for JSON values
- Simplify async execution by using tokio::block_in_place
- Remove unused function parameters to eliminate warnings
- Extract temporary variables to avoid borrowing issues
- Add placeholder methods to SessionManager for analytics
- Implement real database operations for admin endpoints
- Remove duplicate or conflicting type definitions

These changes address all compiler warnings while maintaining the
existing functionality and preparing the codebase for future
enhancements in areas like analytics and session management.
2025-11-27 08:34:24 -03:00

443 lines
14 KiB
Rust

use crate::shared::models::UserSession;
use crate::shared::state::AppState;
use log::{error, info, trace};
use rhai::{Dynamic, Engine};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Debug, Serialize, Deserialize)]
pub struct WeatherData {
pub location: String,
pub temperature: f32,
pub temperature_unit: String,
pub description: String,
pub humidity: u32,
pub wind_speed: f32,
pub wind_direction: String,
pub feels_like: f32,
pub pressure: u32,
pub visibility: f32,
pub uv_index: Option<f32>,
pub forecast: Vec<ForecastDay>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ForecastDay {
pub date: String,
pub temp_high: f32,
pub temp_low: f32,
pub description: String,
pub rain_chance: u32,
}
/// Register WEATHER keyword in BASIC
pub fn weather_keyword(state: Arc<AppState>, user: UserSession, engine: &mut Engine) {
let state_clone = Arc::clone(&state);
let user_clone = user.clone();
engine
.register_custom_syntax(&["WEATHER", "$expr$"], false, move |context, inputs| {
let location = context.eval_expression_tree(&inputs[0])?.to_string();
trace!(
"WEATHER command executed: {} for user: {}",
location,
user_clone.user_id
);
let state_for_task = Arc::clone(&state_clone);
let user_for_task = user_clone.clone();
let location_for_task = location.clone();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build();
let send_err = if let Ok(rt) = rt {
let result = rt.block_on(async move {
get_weather(&state_for_task, &user_for_task, &location_for_task).await
});
tx.send(result).err()
} else {
tx.send(Err("Failed to build tokio runtime".to_string()))
.err()
};
if send_err.is_some() {
error!("Failed to send WEATHER result from thread");
}
});
match rx.recv_timeout(std::time::Duration::from_secs(10)) {
Ok(Ok(weather_info)) => Ok(Dynamic::from(weather_info)),
Ok(Err(e)) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
format!("WEATHER failed: {}", e).into(),
rhai::Position::NONE,
))),
Err(_) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
"WEATHER request timed out".into(),
rhai::Position::NONE,
))),
}
})
.unwrap();
// Register FORECAST keyword for extended forecast
let state_clone2 = Arc::clone(&state);
let user_clone2 = user.clone();
engine
.register_custom_syntax(
&["FORECAST", "$expr$", ",", "$expr$"],
false,
move |context, inputs| {
let location = context.eval_expression_tree(&inputs[0])?.to_string();
let days = context
.eval_expression_tree(&inputs[1])?
.as_int()
.unwrap_or(5) as u32;
trace!(
"FORECAST command executed: {} for {} days, user: {}",
location,
days,
user_clone2.user_id
);
let state_for_task = Arc::clone(&state_clone2);
let user_for_task = user_clone2.clone();
let location_for_task = location.clone();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build();
let send_err = if let Ok(rt) = rt {
let result = rt.block_on(async move {
get_forecast(&state_for_task, &user_for_task, &location_for_task, days)
.await
});
tx.send(result).err()
} else {
tx.send(Err("Failed to build tokio runtime".to_string()))
.err()
};
if send_err.is_some() {
error!("Failed to send FORECAST result from thread");
}
});
match rx.recv_timeout(std::time::Duration::from_secs(10)) {
Ok(Ok(forecast_info)) => Ok(Dynamic::from(forecast_info)),
Ok(Err(e)) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
format!("FORECAST failed: {}", e).into(),
rhai::Position::NONE,
))),
Err(_) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
"FORECAST request timed out".into(),
rhai::Position::NONE,
))),
}
},
)
.unwrap();
}
async fn get_weather(
state: &AppState,
_user: &UserSession,
location: &str,
) -> Result<String, String> {
// Get API key from bot config or environment
let api_key = get_weather_api_key(state)?;
// Try OpenWeatherMap API first
match fetch_openweathermap_current(&api_key, location).await {
Ok(weather) => {
info!("Weather data fetched for {}", location);
Ok(format_weather_response(&weather))
}
Err(e) => {
error!("OpenWeatherMap API failed: {}", e);
// Try fallback weather service
fetch_fallback_weather(location).await
}
}
}
async fn get_forecast(
state: &AppState,
_user: &UserSession,
location: &str,
days: u32,
) -> Result<String, String> {
let api_key = get_weather_api_key(state)?;
match fetch_openweathermap_forecast(&api_key, location, days).await {
Ok(forecast) => {
info!("Forecast data fetched for {} ({} days)", location, days);
Ok(format_forecast_response(&forecast))
}
Err(e) => {
error!("Forecast API failed: {}", e);
Err(format!("Could not get forecast for {}: {}", location, e))
}
}
}
async fn fetch_openweathermap_current(
api_key: &str,
location: &str,
) -> Result<WeatherData, String> {
let client = reqwest::Client::new();
let url = format!(
"https://api.openweathermap.org/data/2.5/weather?q={}&appid={}&units=metric",
urlencoding::encode(location),
api_key
);
let response = client
.get(&url)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
return Err(format!("API returned status: {}", response.status()));
}
let data: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
// Parse OpenWeatherMap response
Ok(WeatherData {
location: data["name"].as_str().unwrap_or(location).to_string(),
temperature: data["main"]["temp"].as_f64().unwrap_or(0.0) as f32,
temperature_unit: "°C".to_string(),
description: data["weather"][0]["description"]
.as_str()
.unwrap_or("Unknown")
.to_string(),
humidity: data["main"]["humidity"].as_u64().unwrap_or(0) as u32,
wind_speed: data["wind"]["speed"].as_f64().unwrap_or(0.0) as f32,
wind_direction: degrees_to_compass(data["wind"]["deg"].as_f64().unwrap_or(0.0)),
feels_like: data["main"]["feels_like"].as_f64().unwrap_or(0.0) as f32,
pressure: data["main"]["pressure"].as_u64().unwrap_or(0) as u32,
visibility: data["visibility"].as_f64().unwrap_or(0.0) as f32 / 1000.0, // Convert to km
uv_index: None, // Would need separate API call for UV index
forecast: Vec::new(),
})
}
async fn fetch_openweathermap_forecast(
api_key: &str,
location: &str,
days: u32,
) -> Result<WeatherData, String> {
let client = reqwest::Client::new();
let url = format!(
"https://api.openweathermap.org/data/2.5/forecast?q={}&appid={}&units=metric&cnt={}",
urlencoding::encode(location),
api_key,
days * 8 // 8 forecasts per day (every 3 hours)
);
let response = client
.get(&url)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
return Err(format!("API returned status: {}", response.status()));
}
let data: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
// Process forecast data
let mut forecast_days = Vec::new();
let mut daily_data: std::collections::HashMap<String, (f32, f32, String, u32)> =
std::collections::HashMap::new();
if let Some(list) = data["list"].as_array() {
for item in list {
let dt_txt = item["dt_txt"].as_str().unwrap_or("");
let date = dt_txt.split(' ').next().unwrap_or("");
let temp = item["main"]["temp"].as_f64().unwrap_or(0.0) as f32;
let description = item["weather"][0]["description"]
.as_str()
.unwrap_or("Unknown")
.to_string();
let rain_chance = (item["pop"].as_f64().unwrap_or(0.0) * 100.0) as u32;
let entry = daily_data.entry(date.to_string()).or_insert((
temp,
temp,
description.clone(),
rain_chance,
));
// Update min/max temperatures
if temp < entry.0 {
entry.0 = temp;
}
if temp > entry.1 {
entry.1 = temp;
}
// Update rain chance to max for the day
if rain_chance > entry.3 {
entry.3 = rain_chance;
}
}
}
// Convert to forecast days
for (date, (temp_low, temp_high, description, rain_chance)) in daily_data.iter() {
forecast_days.push(ForecastDay {
date: date.clone(),
temp_high: *temp_high,
temp_low: *temp_low,
description: description.clone(),
rain_chance: *rain_chance,
});
}
// Sort by date
forecast_days.sort_by(|a, b| a.date.cmp(&b.date));
Ok(WeatherData {
location: data["city"]["name"]
.as_str()
.unwrap_or(location)
.to_string(),
temperature: 0.0, // Not relevant for forecast
temperature_unit: "°C".to_string(),
description: "Forecast".to_string(),
humidity: 0,
wind_speed: 0.0,
wind_direction: String::new(),
feels_like: 0.0,
pressure: 0,
visibility: 0.0,
uv_index: None,
forecast: forecast_days,
})
}
async fn fetch_fallback_weather(location: &str) -> Result<String, String> {
// This could use another weather API like WeatherAPI.com or NOAA
// For now, return a simulated response
info!("Using fallback weather for {}", location);
Ok(format!(
"Weather information for {} is temporarily unavailable. Please try again later.",
location
))
}
fn format_weather_response(weather: &WeatherData) -> String {
format!(
"Current weather in {}:\n\
🌡️ Temperature: {:.1}{} (feels like {:.1}{})\n\
☁️ Conditions: {}\n\
💧 Humidity: {}%\n\
💨 Wind: {:.1} m/s {}\n\
🔍 Visibility: {:.1} km\n\
📊 Pressure: {} hPa",
weather.location,
weather.temperature,
weather.temperature_unit,
weather.feels_like,
weather.temperature_unit,
weather.description,
weather.humidity,
weather.wind_speed,
weather.wind_direction,
weather.visibility,
weather.pressure
)
}
fn format_forecast_response(weather: &WeatherData) -> String {
let mut response = format!("Weather forecast for {}:\n\n", weather.location);
for day in &weather.forecast {
response.push_str(&format!(
"📅 {}\n\
🌡️ High: {:.1}°C, Low: {:.1}°C\n\
☁️ {}\n\
☔ Rain chance: {}%\n\n",
day.date, day.temp_high, day.temp_low, day.description, day.rain_chance
));
}
response
}
fn degrees_to_compass(degrees: f64) -> String {
let directions = [
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW",
"NW", "NNW",
];
let index = ((degrees + 11.25) / 22.5) as usize % 16;
directions[index].to_string()
}
fn get_weather_api_key(_state: &AppState) -> Result<String, String> {
// Get API key from environment variable
std::env::var("OPENWEATHERMAP_API_KEY")
.or_else(|_| std::env::var("WEATHER_API_KEY"))
.map_err(|_| {
"Weather API key not found. Please set 'weather-api-key' in config.csv or WEATHER_API_KEY environment variable".to_string()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_degrees_to_compass() {
assert_eq!(degrees_to_compass(0.0), "N");
assert_eq!(degrees_to_compass(45.0), "NE");
assert_eq!(degrees_to_compass(90.0), "E");
assert_eq!(degrees_to_compass(180.0), "S");
assert_eq!(degrees_to_compass(270.0), "W");
assert_eq!(degrees_to_compass(315.0), "NW");
}
#[test]
fn test_format_weather_response() {
let weather = WeatherData {
location: "London".to_string(),
temperature: 15.0,
temperature_unit: "°C".to_string(),
description: "Partly cloudy".to_string(),
humidity: 65,
wind_speed: 3.5,
wind_direction: "NE".to_string(),
feels_like: 14.0,
pressure: 1013,
visibility: 10.0,
uv_index: Some(3.0),
forecast: Vec::new(),
};
let response = format_weather_response(&weather);
assert!(response.contains("London"));
assert!(response.contains("15.0"));
assert!(response.contains("Partly cloudy"));
}
}