From ea26e25546c05e9ca8946081f6e046596053ed2e Mon Sep 17 00:00:00 2001 From: reverse Date: Wed, 29 Jul 2026 17:32:33 +0000 Subject: [PATCH] Add weather module Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 +- README.md | 6 +- src/bot/mod.rs | 2 +- src/bot/modules/mod.rs | 4 +- src/bot/modules/weather.rs | 262 +++++++++++++++++++++++++++++++++++++ tests/weather.rs | 67 ++++++++++ 6 files changed, 339 insertions(+), 5 deletions(-) create mode 100644 src/bot/modules/weather.rs create mode 100644 tests/weather.rs diff --git a/.gitignore b/.gitignore index 49ac0ec..af888df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target Cargo.lock rustbot.service -rustbot.conf \ No newline at end of file +rustbot.conf +weather.*.json \ No newline at end of file diff --git a/README.md b/README.md index 1afc243..92f4327 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,10 @@ pub fn all() -> Vec> { } ``` -Shipped modules: **`builtins`** (`ping`, `echo`, `hello`) and **`dice`** -(`roll [NdM]`). Module tests live in `tests/modules.rs` — construct a module and +Shipped modules: **`builtins`** (`ping`, `echo`, `hello`), **`dice`** +(`roll [NdM]`), and **`weather`** (`add ` then `w [location]` via +wttr.in; per-user locations saved to a per-network JSON file, path from +`RUSTBOT_DATA_DIR`). Module tests live in `tests/` — construct a module and assert on the `Action`s it returns, no network required. ## Build & run diff --git a/src/bot/mod.rs b/src/bot/mod.rs index 8568669..6bb98e0 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -28,7 +28,7 @@ impl Bot { pub fn new(config: Config, conn: Connection) -> Bot { let current_nick = config.nick.clone(); - let mods = modules::all(); + let mods = modules::all(&config.name); let mut routes: HashMap<&'static str, usize> = HashMap::new(); for (i, m) in mods.iter().enumerate() { for spec in m.commands() { diff --git a/src/bot/modules/mod.rs b/src/bot/modules/mod.rs index 9dd67f5..c12f042 100644 --- a/src/bot/modules/mod.rs +++ b/src/bot/modules/mod.rs @@ -1,11 +1,13 @@ pub mod builtins; pub mod dice; +pub mod weather; use super::module::Module; -pub fn all() -> Vec> { +pub fn all(network: &str) -> Vec> { vec![ Box::new(builtins::Builtins), Box::new(dice::Dice::new()), + Box::new(weather::Weather::new(network)), ] } diff --git a/src/bot/modules/weather.rs b/src/bot/modules/weather.rs new file mode 100644 index 0000000..2ff4263 --- /dev/null +++ b/src/bot/modules/weather.rs @@ -0,0 +1,262 @@ +use std::collections::BTreeMap; +use std::fs; +use std::io::{self, Read, Write}; +use std::iter::Peekable; +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::{Path, PathBuf}; +use std::str::Chars; +use std::time::Duration; + +use native_tls::TlsConnector; + +use crate::bot::module::{Action, Command, CommandSpec, Module}; + +const COMMANDS: &[CommandSpec] = &[ + CommandSpec { name: "add", usage: "add ", about: "save your weather location" }, + CommandSpec { name: "w", usage: "w [location]", about: "weather for your saved location" }, +]; + +pub struct Weather { + store: Store, + path: PathBuf, +} + +impl Weather { + pub fn new(network: &str) -> Weather { + let dir = std::env::var("RUSTBOT_DATA_DIR").unwrap_or_else(|_| ".".to_string()); + let safe: String = network + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + Weather::with_path(PathBuf::from(dir).join(format!("weather.{safe}.json"))) + } + + pub fn with_path(path: PathBuf) -> Weather { + Weather { store: Store::load(&path), path } + } + + pub fn location_of(&self, nick: &str) -> Option<&str> { + self.store.get(nick).map(String::as_str) + } +} + +impl Module for Weather { + fn name(&self) -> &'static str { + "weather" + } + + fn commands(&self) -> &'static [CommandSpec] { + COMMANDS + } + + fn on_command(&mut self, cmd: &Command) -> Vec { + match cmd.name { + "add" => { + if cmd.args.is_empty() { + return vec![Action::reply(format!("usage: {}add ", cmd.prefix))]; + } + let loc = cmd.args.join(" "); + self.store.set(cmd.sender, &loc); + if let Err(e) = self.store.save(&self.path) { + crate::log(&format!("weather: could not save {}: {e}", self.path.display())); + return vec![Action::reply(format!("{}: could not save location", cmd.sender))]; + } + vec![Action::reply(format!("{}: saved {loc} — try {}w", cmd.sender, cmd.prefix))] + } + "w" => { + let loc = if !cmd.args.is_empty() { + cmd.args.join(" ") + } else if let Some(l) = self.store.get(cmd.sender) { + l.clone() + } else { + return vec![Action::reply(format!( + "{}: no location set — {}add first", + cmd.sender, cmd.prefix + ))]; + }; + match fetch_weather(&loc) { + Ok(text) => vec![Action::reply(text)], + Err(e) => vec![Action::reply(format!("weather unavailable: {e}"))], + } + } + _ => vec![], + } + } +} + +struct Store { + map: BTreeMap, +} + +impl Store { + fn load(path: &Path) -> Store { + match fs::read_to_string(path) { + Ok(s) => Store { map: parse_db(&s) }, + Err(_) => Store { map: BTreeMap::new() }, + } + } + + fn save(&self, path: &Path) -> io::Result<()> { + fs::write(path, to_json(&self.map) + "\n") + } + + fn get(&self, nick: &str) -> Option<&String> { + self.map.get(&nick.to_lowercase()) + } + + fn set(&mut self, nick: &str, loc: &str) { + self.map.insert(nick.to_lowercase(), loc.to_string()); + } +} + +fn fetch_weather(location: &str) -> Result { + let path = format!("/{}?format=%l:+%C+%t+feels+%f+%w+%h&m", url_encode(location)); + let body = https_get("wttr.in", &path)?; + let line: String = body + .lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .unwrap_or("") + .chars() + .take(250) + .collect(); + if line.is_empty() { + return Err("no data".to_string()); + } + Ok(line) +} + +fn https_get(host: &str, path: &str) -> Result { + let addr = (host, 443) + .to_socket_addrs() + .map_err(|e| e.to_string())? + .next() + .ok_or_else(|| "dns lookup failed".to_string())?; + let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5)).map_err(|e| e.to_string())?; + stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); + stream.set_write_timeout(Some(Duration::from_secs(5))).ok(); + + let connector = TlsConnector::new().map_err(|e| e.to_string())?; + let mut tls = connector.connect(host, stream).map_err(|e| e.to_string())?; + + let req = format!( + "GET {path} HTTP/1.0\r\nHost: {host}\r\nUser-Agent: curl/rubot\r\nAccept: text/plain\r\nConnection: close\r\n\r\n" + ); + tls.write_all(req.as_bytes()).map_err(|e| e.to_string())?; + + let mut buf = Vec::new(); + tls.read_to_end(&mut buf).map_err(|e| e.to_string())?; + let text = String::from_utf8_lossy(&buf); + match text.split_once("\r\n\r\n") { + Some((_, body)) => Ok(body.to_string()), + None => Err("malformed response".to_string()), + } +} + +fn url_encode(s: &str) -> String { + let mut out = String::new(); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char), + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +pub fn to_json(map: &BTreeMap) -> String { + let items: Vec = map + .iter() + .map(|(k, v)| format!("\"{}\":\"{}\"", json_escape(k), json_escape(v))) + .collect(); + format!("{{{}}}", items.join(",")) +} + +pub fn parse_db(s: &str) -> BTreeMap { + let mut map = BTreeMap::new(); + let mut chars = s.chars().peekable(); + skip_ws(&mut chars); + if chars.next() != Some('{') { + return map; + } + loop { + skip_ws(&mut chars); + match chars.peek() { + Some('"') => {} + Some(',') => { + chars.next(); + continue; + } + _ => break, + } + let Some(key) = parse_string(&mut chars) else { break }; + skip_ws(&mut chars); + if chars.next() != Some(':') { + break; + } + skip_ws(&mut chars); + let Some(value) = parse_string(&mut chars) else { break }; + map.insert(key, value); + } + map +} + +fn skip_ws(chars: &mut Peekable) { + while let Some(c) = chars.peek() { + if c.is_whitespace() { + chars.next(); + } else { + break; + } + } +} + +fn parse_string(chars: &mut Peekable) -> Option { + if chars.next() != Some('"') { + return None; + } + let mut out = String::new(); + while let Some(c) = chars.next() { + match c { + '"' => return Some(out), + '\\' => match chars.next()? { + '"' => out.push('"'), + '\\' => out.push('\\'), + '/' => out.push('/'), + 'n' => out.push('\n'), + 'r' => out.push('\r'), + 't' => out.push('\t'), + 'b' => out.push('\u{08}'), + 'f' => out.push('\u{0C}'), + 'u' => { + let mut code = 0u32; + for _ in 0..4 { + code = code * 16 + chars.next()?.to_digit(16)?; + } + if let Some(ch) = char::from_u32(code) { + out.push(ch); + } + } + other => out.push(other), + }, + c => out.push(c), + } + } + None +} + +fn json_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out +} diff --git a/tests/weather.rs b/tests/weather.rs new file mode 100644 index 0000000..2b4c303 --- /dev/null +++ b/tests/weather.rs @@ -0,0 +1,67 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; + +use rustbot::bot::module::{Action, Command, Module}; +use rustbot::bot::modules::weather::{parse_db, to_json, Weather}; + +fn cmd<'a>(sender: &'a str, name: &'a str, args: &'a [&'a str]) -> Command<'a> { + Command { sender, reply_to: "#chan", name, args, prefix: ">", network: "test" } +} + +fn reply(actions: &[Action]) -> &str { + match actions { + [Action::Reply(s)] => s, + _ => panic!("expected exactly one Reply"), + } +} + +fn temp(name: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!("rubot-test-{name}.json")); + let _ = std::fs::remove_file(&p); + p +} + +#[test] +fn json_round_trip_handles_special_chars() { + let mut m = BTreeMap::new(); + m.insert("alice".to_string(), "73700".to_string()); + m.insert("bob".to_string(), "Saint-Étienne, FR".to_string()); + m.insert("carol".to_string(), "a\"b\\c\nd".to_string()); + let back = parse_db(&to_json(&m)); + assert_eq!(m, back); +} + +#[test] +fn parse_db_tolerates_junk() { + assert!(parse_db("").is_empty()); + assert!(parse_db("not json").is_empty()); + assert_eq!(parse_db("{}").len(), 0); + assert_eq!(parse_db("{\"a\":\"b\"}").get("a").map(String::as_str), Some("b")); +} + +#[test] +fn add_saves_and_persists_case_insensitively() { + let path = temp("weather-add"); + let mut w = Weather::with_path(path.clone()); + let r = reply(&w.on_command(&cmd("Alice", "add", &["73700"]))).to_string(); + assert!(r.contains("73700"), "reply: {r:?}"); + + let w2 = Weather::with_path(path.clone()); + assert_eq!(w2.location_of("alice"), Some("73700")); + assert_eq!(w2.location_of("ALICE"), Some("73700")); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn add_without_arg_shows_usage() { + let mut w = Weather::with_path(temp("weather-usage")); + let r = reply(&w.on_command(&cmd("alice", "add", &[]))).to_string(); + assert!(r.contains("usage"), "reply: {r:?}"); +} + +#[test] +fn w_without_saved_location_hints_to_add() { + let mut w = Weather::with_path(temp("weather-hint")); + let r = reply(&w.on_command(&cmd("nobody", "w", &[]))).to_string(); + assert!(r.contains("add"), "reply: {r:?}"); +}