diff --git a/README.md b/README.md index 0958c4e..8dc94cb 100644 --- a/README.md +++ b/README.md @@ -55,10 +55,13 @@ pub fn all() -> Vec> { Shipped modules: **`builtins`** (`ping`, `echo`, `hello`), **`dice`** (`roll [NdM]`), **`hash`** (`md5`/`sha1`/`sha256`/`hash `, hand-rolled), -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. +**`weather`** (`add ` then `w [location]` via wttr.in; per-user +locations saved to a per-network JSON file, path from `RUSTBOT_DATA_DIR`), and +web lookups over the shared `http`/`json` helpers: **`crypto `** (Bitstamp), +**`wiki `**, **`define `**, **`urban `**, **`tinyurl `**, +**`down `**. Module tests live in `tests/` — construct a module (or call +its pure formatter with a canned API body) and assert on the result, no network +required. ## Build & run diff --git a/src/bot/modules/crypto.rs b/src/bot/modules/crypto.rs new file mode 100644 index 0000000..16b67ab --- /dev/null +++ b/src/bot/modules/crypto.rs @@ -0,0 +1,63 @@ +use crate::bot::module::{Action, Command, CommandSpec, Module}; +use crate::json::Json; + +pub struct Crypto; + +const COMMANDS: &[CommandSpec] = &[CommandSpec { + name: "crypto", + usage: "crypto ", + about: "USD price of a coin (e.g. crypto btc), via Bitstamp", +}]; + +impl Module for Crypto { + fn name(&self) -> &'static str { + "crypto" + } + + fn commands(&self) -> &'static [CommandSpec] { + COMMANDS + } + + fn on_command(&mut self, cmd: &Command) -> Vec { + let Some(sym) = cmd.args.first() else { + return vec![Action::reply(format!( + "usage: {}crypto ", + cmd.prefix + ))]; + }; + let sym = sym.to_lowercase(); + if sym.is_empty() || !sym.chars().all(|c| c.is_ascii_alphanumeric()) { + return vec![Action::reply("invalid symbol".to_string())]; + } + let path = format!("/api/v2/ticker/{sym}usd"); + match crate::http::get("www.bitstamp.net", &path) { + Ok(body) => vec![Action::reply(format_ticker(&sym, &body))], + Err(e) => { + let msg = if e.contains("404") { + format!("no such pair '{}usd'", sym.to_uppercase()) + } else { + format!("crypto unavailable: {e}") + }; + vec![Action::reply(msg)] + } + } + } +} + +pub fn format_ticker(sym: &str, body: &str) -> String { + let up = sym.to_uppercase(); + let Some(j) = Json::parse(body) else { + return format!("no data for {up}"); + }; + let num = |k: &str| { + j.get(k) + .and_then(Json::as_str) + .and_then(|s| s.parse::().ok()) + }; + match (num("last"), num("high"), num("low")) { + (Some(last), Some(high), Some(low)) => { + format!("{up}/USD: ${last:.2} (high ${high:.2} / low ${low:.2})") + } + _ => format!("no data for {up}"), + } +} diff --git a/src/bot/modules/define.rs b/src/bot/modules/define.rs new file mode 100644 index 0000000..3247343 --- /dev/null +++ b/src/bot/modules/define.rs @@ -0,0 +1,73 @@ +use crate::bot::module::{Action, Command, CommandSpec, Module}; +use crate::json::Json; + +pub struct Define; + +const COMMANDS: &[CommandSpec] = &[CommandSpec { + name: "define", + usage: "define ", + about: "dictionary definition of a word", +}]; + +impl Module for Define { + fn name(&self) -> &'static str { + "define" + } + + fn commands(&self) -> &'static [CommandSpec] { + COMMANDS + } + + fn on_command(&mut self, cmd: &Command) -> Vec { + let Some(word) = cmd.args.first() else { + return vec![Action::reply(format!("usage: {}define ", cmd.prefix))]; + }; + let word = word.to_lowercase(); + let path = format!("/api/v2/entries/en/{}", crate::http::url_encode(&word)); + match crate::http::get("api.dictionaryapi.dev", &path) { + Ok(body) => vec![Action::reply(format_define(&word, &body))], + Err(e) => { + let msg = if e.contains("404") { + format!("no definition for '{word}'") + } else { + format!("define unavailable: {e}") + }; + vec![Action::reply(msg)] + } + } + } +} + +pub fn format_define(word: &str, body: &str) -> String { + let Some(j) = Json::parse(body) else { + return format!("no definition for '{word}'"); + }; + let meaning = j + .idx(0) + .and_then(|e| e.get("meanings")) + .and_then(|m| m.idx(0)); + let pos = meaning + .and_then(|m| m.get("partOfSpeech")) + .and_then(Json::as_str) + .unwrap_or(""); + let def = meaning + .and_then(|m| m.get("definitions")) + .and_then(|d| d.idx(0)) + .and_then(|d| d.get("definition")) + .and_then(Json::as_str); + match def { + Some(d) => { + let mut out = if pos.is_empty() { + format!("{word}: {d}") + } else { + format!("{word} ({pos}): {d}") + }; + if out.chars().count() > 300 { + out = out.chars().take(300).collect::(); + out.push('
'); + } + out + } + None => format!("no definition for '{word}'"), + } +} diff --git a/src/bot/modules/down.rs b/src/bot/modules/down.rs new file mode 100644 index 0000000..058811c --- /dev/null +++ b/src/bot/modules/down.rs @@ -0,0 +1,64 @@ +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; + +use crate::bot::module::{Action, Command, CommandSpec, Module}; + +pub struct Down; + +const COMMANDS: &[CommandSpec] = &[CommandSpec { + name: "down", + usage: "down ", + about: "check whether a site is reachable", +}]; + +impl Module for Down { + fn name(&self) -> &'static str { + "down" + } + + fn commands(&self) -> &'static [CommandSpec] { + COMMANDS + } + + fn on_command(&mut self, cmd: &Command) -> Vec { + let Some(arg) = cmd.args.first() else { + return vec![Action::reply(format!("usage: {}down ", cmd.prefix))]; + }; + let host = host_of(arg); + if host.is_empty() { + return vec![Action::reply(format!("usage: {}down ", cmd.prefix))]; + } + let up = reachable(&host, 443) || reachable(&host, 80); + let msg = if up { + format!("{host} looks up 👍") + } else { + format!("{host} looks down 👎") + }; + vec![Action::reply(msg)] + } +} + +pub fn host_of(input: &str) -> String { + let mut s = input.trim(); + for p in ["https://", "http://", "//"] { + if let Some(rest) = s.strip_prefix(p) { + s = rest; + break; + } + } + s = s.split(['/', '?', '#']).next().unwrap_or(""); + if let Some((_, after)) = s.rsplit_once('@') { + s = after; + } + s.split(':').next().unwrap_or("").to_string() +} + +fn reachable(host: &str, port: u16) -> bool { + let Ok(mut addrs) = (host, port).to_socket_addrs() else { + return false; + }; + let Some(addr) = addrs.next() else { + return false; + }; + TcpStream::connect_timeout(&addr, Duration::from_secs(4)).is_ok() +} diff --git a/src/bot/modules/mod.rs b/src/bot/modules/mod.rs index fdf7052..c5278f4 100644 --- a/src/bot/modules/mod.rs +++ b/src/bot/modules/mod.rs @@ -1,7 +1,13 @@ pub mod builtins; +pub mod crypto; +pub mod define; pub mod dice; +pub mod down; pub mod hash; +pub mod tinyurl; +pub mod urban; pub mod weather; +pub mod wiki; use super::module::Module; @@ -10,6 +16,12 @@ pub fn all(network: &str) -> Vec> { Box::new(builtins::Builtins), Box::new(dice::Dice::new()), Box::new(hash::Hash), + Box::new(crypto::Crypto), + Box::new(define::Define), + Box::new(down::Down), + Box::new(tinyurl::Tinyurl), + Box::new(urban::Urban), Box::new(weather::Weather::new(network)), + Box::new(wiki::Wiki), ] } diff --git a/src/bot/modules/tinyurl.rs b/src/bot/modules/tinyurl.rs new file mode 100644 index 0000000..44459d6 --- /dev/null +++ b/src/bot/modules/tinyurl.rs @@ -0,0 +1,37 @@ +use crate::bot::module::{Action, Command, CommandSpec, Module}; + +pub struct Tinyurl; + +const COMMANDS: &[CommandSpec] = &[CommandSpec { + name: "tinyurl", + usage: "tinyurl ", + about: "shorten a URL via tinyurl.com", +}]; + +impl Module for Tinyurl { + fn name(&self) -> &'static str { + "tinyurl" + } + + fn commands(&self) -> &'static [CommandSpec] { + COMMANDS + } + + fn on_command(&mut self, cmd: &Command) -> Vec { + let Some(url) = cmd.args.first() else { + return vec![Action::reply(format!("usage: {}tinyurl ", cmd.prefix))]; + }; + let path = format!("/api-create.php?url={}", crate::http::url_encode(url)); + match crate::http::get("tinyurl.com", &path) { + Ok(body) => { + let short = body.trim(); + if short.starts_with("http") { + vec![Action::reply(short.to_string())] + } else { + vec![Action::reply("couldn't shorten that URL".to_string())] + } + } + Err(e) => vec![Action::reply(format!("tinyurl unavailable: {e}"))], + } + } +} diff --git a/src/bot/modules/urban.rs b/src/bot/modules/urban.rs new file mode 100644 index 0000000..9051a6b --- /dev/null +++ b/src/bot/modules/urban.rs @@ -0,0 +1,58 @@ +use crate::bot::module::{Action, Command, CommandSpec, Module}; +use crate::json::Json; + +pub struct Urban; + +const COMMANDS: &[CommandSpec] = &[CommandSpec { + name: "urban", + usage: "urban ", + about: "urban dictionary definition", +}]; + +impl Module for Urban { + fn name(&self) -> &'static str { + "urban" + } + + fn commands(&self) -> &'static [CommandSpec] { + COMMANDS + } + + fn on_command(&mut self, cmd: &Command) -> Vec { + let term = cmd.args.join(" "); + if term.is_empty() { + return vec![Action::reply(format!("usage: {}urban ", cmd.prefix))]; + } + let path = format!("/v0/define?term={}", crate::http::url_encode(&term)); + match crate::http::get("api.urbandictionary.com", &path) { + Ok(body) => vec![Action::reply(format_urban(&term, &body))], + Err(e) => vec![Action::reply(format!("urban unavailable: {e}"))], + } + } +} + +pub fn format_urban(term: &str, body: &str) -> String { + let Some(j) = Json::parse(body) else { + return format!("no results for '{term}'"); + }; + let first = j.get("list").and_then(|l| l.idx(0)); + let word = first + .and_then(|d| d.get("word")) + .and_then(Json::as_str) + .unwrap_or(term); + let def = first + .and_then(|d| d.get("definition")) + .and_then(Json::as_str); + match def { + Some(d) => { + let clean = d.replace(['\r', '\n'], " ").replace(['[', ']'], ""); + let mut out = format!("{word}: {clean}"); + if out.chars().count() > 320 { + out = out.chars().take(320).collect::(); + out.push('
'); + } + out + } + None => format!("no results for '{term}'"), + } +} diff --git a/src/bot/modules/weather.rs b/src/bot/modules/weather.rs index 43c2d67..824e65d 100644 --- a/src/bot/modules/weather.rs +++ b/src/bot/modules/weather.rs @@ -1,14 +1,11 @@ use std::collections::{BTreeMap, HashMap}; use std::fs; -use std::io::{self, Read, Write}; +use std::io; use std::iter::Peekable; -use std::net::{TcpStream, ToSocketAddrs}; use std::path::{Path, PathBuf}; use std::str::Chars; use std::time::{Duration, Instant}; -use native_tls::TlsConnector; - use crate::bot::module::{Action, Command, CommandSpec, Module}; const CACHE_TTL: Duration = Duration::from_secs(600); @@ -148,34 +145,20 @@ impl Store { } fn fetch_weather(location: &str) -> Result { - let path = format!("/{}?format=%l|%c|%C|%t|%f|%w|%h&m", url_encode(location)); - let mut last = "no response".to_string(); - for attempt in 0..3 { - if attempt > 0 { - std::thread::sleep(Duration::from_millis(250)); - } - match https_get("wttr.in", &path) { - Ok(body) => { - let raw = body - .lines() - .map(str::trim) - .find(|l| !l.is_empty()) - .unwrap_or(""); - if !raw.is_empty() { - return Ok(format_weather(raw, location)); - } - last = "empty response".to_string(); - } - Err(e) => { - let retry = e.retry; - last = e.msg; - if !retry { - break; - } - } - } + let path = format!( + "/{}?format=%l|%c|%C|%t|%f|%w|%h&m", + crate::http::url_encode(location) + ); + let body = crate::http::get("wttr.in", &path)?; + let raw = body + .lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .unwrap_or(""); + if raw.is_empty() { + return Err("no data".to_string()); } - Err(last) + Ok(format_weather(raw, location)) } pub fn format_weather(raw: &str, location: &str) -> String { @@ -196,101 +179,6 @@ pub fn format_weather(raw: &str, location: &str) -> String { raw.chars().take(250).collect() } -struct HttpErr { - msg: String, - retry: bool, -} - -fn https_get(host: &str, path: &str) -> Result { - let addr = (host, 443) - .to_socket_addrs() - .map_err(|e| HttpErr { - msg: e.to_string(), - retry: false, - })? - .next() - .ok_or(HttpErr { - msg: "dns lookup failed".to_string(), - retry: false, - })?; - let stream = - TcpStream::connect_timeout(&addr, Duration::from_secs(4)).map_err(|e| HttpErr { - retry: retryable_io(&e), - msg: 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| HttpErr { - msg: e.to_string(), - retry: false, - })?; - let mut tls = connector.connect(host, stream).map_err(|e| HttpErr { - msg: e.to_string(), - retry: true, - })?; - - 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| HttpErr { - retry: retryable_io(&e), - msg: e.to_string(), - })?; - - let mut buf = Vec::new(); - tls.read_to_end(&mut buf).map_err(|e| HttpErr { - retry: retryable_io(&e), - msg: e.to_string(), - })?; - - let text = String::from_utf8_lossy(&buf); - let Some((head, body)) = text.split_once("\r\n\r\n") else { - return Err(HttpErr { - msg: "malformed response".to_string(), - retry: true, - }); - }; - let code = head - .lines() - .next() - .unwrap_or("") - .split_whitespace() - .nth(1) - .and_then(|c| c.parse::().ok()) - .unwrap_or(0); - if !(200..300).contains(&code) { - return Err(HttpErr { - retry: code == 429 || code >= 500, - msg: format!("http {code}"), - }); - } - Ok(body.to_string()) -} - -fn retryable_io(e: &io::Error) -> bool { - use io::ErrorKind::{ - BrokenPipe, ConnectionAborted, ConnectionRefused, ConnectionReset, UnexpectedEof, - }; - matches!( - e.kind(), - ConnectionReset | ConnectionAborted | ConnectionRefused | BrokenPipe | UnexpectedEof - ) -} - -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() diff --git a/src/bot/modules/wiki.rs b/src/bot/modules/wiki.rs new file mode 100644 index 0000000..1831d03 --- /dev/null +++ b/src/bot/modules/wiki.rs @@ -0,0 +1,72 @@ +use crate::bot::module::{Action, Command, CommandSpec, Module}; +use crate::json::Json; + +pub struct Wiki; + +const COMMANDS: &[CommandSpec] = &[CommandSpec { + name: "wiki", + usage: "wiki ", + about: "summary of a Wikipedia article", +}]; + +impl Module for Wiki { + fn name(&self) -> &'static str { + "wiki" + } + + fn commands(&self) -> &'static [CommandSpec] { + COMMANDS + } + + fn on_command(&mut self, cmd: &Command) -> Vec { + let term = cmd.args.join(" "); + if term.is_empty() { + return vec![Action::reply(format!("usage: {}wiki ", cmd.prefix))]; + } + let search = format!( + "/w/api.php?action=opensearch&format=json&limit=1&search={}", + crate::http::url_encode(&term) + ); + let title = match crate::http::get("en.wikipedia.org", &search) { + Ok(body) => match pick_title(&body) { + Some(t) => t, + None => return vec![Action::reply(format!("no wikipedia article for '{term}'"))], + }, + Err(e) => return vec![Action::reply(format!("wiki unavailable: {e}"))], + }; + let path = format!( + "/api/rest_v1/page/summary/{}", + crate::http::url_encode(&title.replace(' ', "_")) + ); + match crate::http::get("en.wikipedia.org", &path) { + Ok(body) => vec![Action::reply(format_summary(&body))], + Err(e) => vec![Action::reply(format!("wiki unavailable: {e}"))], + } + } +} + +pub fn pick_title(opensearch_body: &str) -> Option { + let j = Json::parse(opensearch_body)?; + Some(j.idx(1)?.idx(0)?.as_str()?.to_string()) +} + +pub fn format_summary(body: &str) -> String { + let Some(j) = Json::parse(body) else { + return "no data".to_string(); + }; + let title = j.get("title").and_then(Json::as_str).unwrap_or(""); + let extract = j.get("extract").and_then(Json::as_str).unwrap_or(""); + if extract.is_empty() { + return "no summary available".to_string(); + } + let mut out = if title.is_empty() { + extract.to_string() + } else { + format!("{title} — {extract}") + }; + if out.chars().count() > 300 { + out = out.chars().take(300).collect::(); + out.push('
'); + } + out +} diff --git a/src/http.rs b/src/http.rs new file mode 100644 index 0000000..2bcd28e --- /dev/null +++ b/src/http.rs @@ -0,0 +1,120 @@ +use std::io::{self, Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; + +use native_tls::TlsConnector; + +pub fn get(host: &str, path: &str) -> Result { + let mut last = "no response".to_string(); + for attempt in 0..3 { + if attempt > 0 { + std::thread::sleep(Duration::from_millis(250)); + } + match try_get(host, path) { + Ok(body) => return Ok(body), + Err(e) => { + let retry = e.retry; + last = e.msg; + if !retry { + break; + } + } + } + } + Err(last) +} + +pub 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 +} + +struct HttpErr { + msg: String, + retry: bool, +} + +fn try_get(host: &str, path: &str) -> Result { + let addr = (host, 443) + .to_socket_addrs() + .map_err(|e| HttpErr { + msg: e.to_string(), + retry: false, + })? + .next() + .ok_or(HttpErr { + msg: "dns lookup failed".to_string(), + retry: false, + })?; + let stream = + TcpStream::connect_timeout(&addr, Duration::from_secs(4)).map_err(|e| HttpErr { + retry: retryable_io(&e), + msg: 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| HttpErr { + msg: e.to_string(), + retry: false, + })?; + let mut tls = connector.connect(host, stream).map_err(|e| HttpErr { + msg: e.to_string(), + retry: true, + })?; + + let req = format!( + "GET {path} HTTP/1.0\r\nHost: {host}\r\nUser-Agent: curl/rubot\r\nAccept: */*\r\nConnection: close\r\n\r\n" + ); + tls.write_all(req.as_bytes()).map_err(|e| HttpErr { + retry: retryable_io(&e), + msg: e.to_string(), + })?; + + let mut buf = Vec::new(); + tls.read_to_end(&mut buf).map_err(|e| HttpErr { + retry: retryable_io(&e), + msg: e.to_string(), + })?; + + let text = String::from_utf8_lossy(&buf); + let Some((head, body)) = text.split_once("\r\n\r\n") else { + return Err(HttpErr { + msg: "malformed response".to_string(), + retry: true, + }); + }; + let code = head + .lines() + .next() + .unwrap_or("") + .split_whitespace() + .nth(1) + .and_then(|c| c.parse::().ok()) + .unwrap_or(0); + if !(200..300).contains(&code) { + return Err(HttpErr { + retry: code == 429 || code >= 500, + msg: format!("http {code}"), + }); + } + Ok(body.to_string()) +} + +fn retryable_io(e: &io::Error) -> bool { + use io::ErrorKind::{ + BrokenPipe, ConnectionAborted, ConnectionRefused, ConnectionReset, UnexpectedEof, + }; + matches!( + e.kind(), + ConnectionReset | ConnectionAborted | ConnectionRefused | BrokenPipe | UnexpectedEof + ) +} diff --git a/src/json.rs b/src/json.rs new file mode 100644 index 0000000..ca28f4c --- /dev/null +++ b/src/json.rs @@ -0,0 +1,184 @@ +use std::iter::Peekable; +use std::str::Chars; + +#[derive(Debug, Clone, PartialEq)] +pub enum Json { + Null, + Bool(bool), + Num(f64), + Str(String), + Arr(Vec), + Obj(Vec<(String, Json)>), +} + +impl Json { + pub fn parse(s: &str) -> Option { + let mut p = Parser { + it: s.chars().peekable(), + }; + p.ws(); + let v = p.value()?; + Some(v) + } + + pub fn get(&self, key: &str) -> Option<&Json> { + match self { + Json::Obj(m) => m.iter().find(|(k, _)| k == key).map(|(_, v)| v), + _ => None, + } + } + + pub fn idx(&self, i: usize) -> Option<&Json> { + match self { + Json::Arr(a) => a.get(i), + _ => None, + } + } + + pub fn as_str(&self) -> Option<&str> { + match self { + Json::Str(s) => Some(s), + _ => None, + } + } + + pub fn as_f64(&self) -> Option { + match self { + Json::Num(n) => Some(*n), + _ => None, + } + } +} + +struct Parser<'a> { + it: Peekable>, +} + +impl Parser<'_> { + fn ws(&mut self) { + while matches!(self.it.peek(), Some(c) if c.is_whitespace()) { + self.it.next(); + } + } + + fn value(&mut self) -> Option { + self.ws(); + match self.it.peek()? { + '{' => self.object(), + '[' => self.array(), + '"' => Some(Json::Str(self.string()?)), + 't' | 'f' => self.boolean(), + 'n' => self.consume("null").then_some(Json::Null), + _ => self.number(), + } + } + + fn object(&mut self) -> Option { + self.it.next(); + let mut m = Vec::new(); + self.ws(); + if self.it.peek() == Some(&'}') { + self.it.next(); + return Some(Json::Obj(m)); + } + loop { + self.ws(); + let k = self.string()?; + self.ws(); + if self.it.next()? != ':' { + return None; + } + let v = self.value()?; + m.push((k, v)); + self.ws(); + match self.it.next()? { + ',' => continue, + '}' => break, + _ => return None, + } + } + Some(Json::Obj(m)) + } + + fn array(&mut self) -> Option { + self.it.next(); + let mut a = Vec::new(); + self.ws(); + if self.it.peek() == Some(&']') { + self.it.next(); + return Some(Json::Arr(a)); + } + loop { + a.push(self.value()?); + self.ws(); + match self.it.next()? { + ',' => continue, + ']' => break, + _ => return None, + } + } + Some(Json::Arr(a)) + } + + fn string(&mut self) -> Option { + self.ws(); + if self.it.next()? != '"' { + return None; + } + let mut out = String::new(); + loop { + match self.it.next()? { + '"' => return Some(out), + '\\' => match self.it.next()? { + '"' => out.push('"'), + '\\' => out.push('\\'), + '/' => out.push('/'), + 'n' => out.push('\n'), + 't' => out.push('\t'), + 'r' => out.push('\r'), + 'b' => out.push('\u{08}'), + 'f' => out.push('\u{0C}'), + 'u' => { + let mut code = 0u32; + for _ in 0..4 { + code = code * 16 + self.it.next()?.to_digit(16)?; + } + if let Some(c) = char::from_u32(code) { + out.push(c); + } + } + other => out.push(other), + }, + c => out.push(c), + } + } + } + + fn number(&mut self) -> Option { + let mut s = String::new(); + while matches!(self.it.peek(), Some(c) if c.is_ascii_digit() || matches!(c, '-' | '+' | '.' | 'e' | 'E')) + { + s.push(self.it.next()?); + } + s.parse::().ok().map(Json::Num) + } + + fn boolean(&mut self) -> Option { + if self.consume("true") { + Some(Json::Bool(true)) + } else if self.consume("false") { + Some(Json::Bool(false)) + } else { + None + } + } + + fn consume(&mut self, word: &str) -> bool { + for ch in word.chars() { + if self.it.next() != Some(ch) { + return false; + } + } + true + } +} diff --git a/src/lib.rs b/src/lib.rs index 4926187..f70e772 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,9 @@ pub mod bot; pub mod config; +pub mod http; pub mod irc; +pub mod json; pub fn log(msg: &str) { eprintln!("[rustbot] {msg}"); diff --git a/tests/json.rs b/tests/json.rs new file mode 100644 index 0000000..ec15846 --- /dev/null +++ b/tests/json.rs @@ -0,0 +1,38 @@ +use rustbot::json::Json; + +#[test] +fn parses_object_and_navigates() { + let j = Json::parse(r#"{"a":"x","b":{"c":42}}"#).unwrap(); + assert_eq!(j.get("a").and_then(Json::as_str), Some("x")); + assert_eq!( + j.get("b").and_then(|v| v.get("c")).and_then(Json::as_f64), + Some(42.0) + ); + assert!(j.get("missing").is_none()); +} + +#[test] +fn parses_nested_arrays() { + let j = Json::parse(r#"["q",["t1","t2"],[""],["u1"]]"#).unwrap(); + assert_eq!(j.idx(0).and_then(Json::as_str), Some("q")); + assert_eq!( + j.idx(1).and_then(|v| v.idx(1)).and_then(Json::as_str), + Some("t2") + ); +} + +#[test] +fn parses_scalars_and_escapes() { + assert_eq!(Json::parse("true"), Some(Json::Bool(true))); + assert_eq!(Json::parse("null"), Some(Json::Null)); + assert_eq!(Json::parse("-3.5e2").and_then(|j| j.as_f64()), Some(-350.0)); + let s = Json::parse(r#""a\"bĂ©""#).unwrap(); + assert_eq!(s.as_str(), Some("a\"bĂ©")); +} + +#[test] +fn rejects_garbage() { + assert!(Json::parse("{oops").is_none()); + assert!(Json::parse("").is_none()); + assert!(Json::parse("[1,2").is_none()); +} diff --git a/tests/tier4.rs b/tests/tier4.rs new file mode 100644 index 0000000..e3d63e5 --- /dev/null +++ b/tests/tier4.rs @@ -0,0 +1,50 @@ +use rustbot::bot::modules::crypto::format_ticker; +use rustbot::bot::modules::define::format_define; +use rustbot::bot::modules::down::host_of; +use rustbot::bot::modules::urban::format_urban; +use rustbot::bot::modules::wiki::{format_summary, pick_title}; + +#[test] +fn crypto_formats_ticker() { + let body = r#"{"last":"63836.11","high":"64658.00","low":"63477.46","volume":"1032.2"}"#; + let out = format_ticker("btc", body); + assert!(out.contains("BTC/USD: $63836.11"), "{out}"); + assert!(out.contains("high $64658.00"), "{out}"); + assert!(out.contains("low $63477.46"), "{out}"); +} + +#[test] +fn wiki_picks_title_and_formats_summary() { + let os = r#"["Rust programming",["Rust (programming language)"],[""],["https://en.wikipedia.org/wiki/Rust_(programming_language)"]]"#; + assert_eq!( + pick_title(os).as_deref(), + Some("Rust (programming language)") + ); + let sum = r#"{"title":"Rust (programming language)","extract":"Rust is a language."}"#; + assert_eq!( + format_summary(sum), + "Rust (programming language) — Rust is a language." + ); +} + +#[test] +fn define_formats_first_meaning() { + let body = r#"[{"word":"hello","meanings":[{"partOfSpeech":"noun","definitions":[{"definition":"a greeting"}]}]}]"#; + assert_eq!(format_define("hello", body), "hello (noun): a greeting"); +} + +#[test] +fn urban_formats_and_strips_brackets() { + let body = "{\"list\":[{\"word\":\"irc\",\"definition\":\"[Internet] Relay\\r\\nChat\"}]}"; + let out = format_urban("irc", body); + assert!(out.starts_with("irc: Internet Relay"), "{out}"); + assert!(!out.contains('['), "{out}"); +} + +#[test] +fn down_parses_host() { + assert_eq!(host_of("https://example.com/path?x=1"), "example.com"); + assert_eq!(host_of("example.com:8080"), "example.com"); + assert_eq!(host_of("user@host.net"), "host.net"); + assert_eq!(host_of("//foo.org/"), "foo.org"); +}