Add web modules (crypto, wiki, define, urban, tinyurl, down) + shared http/json
All checks were successful
ci / check (push) Successful in 48s

This commit is contained in:
Jean Chevronnet 2026-07-29 19:42:36 +00:00
parent 7846f91afe
commit c3139b4e4d
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
14 changed files with 794 additions and 130 deletions

View file

@ -55,10 +55,13 @@ pub fn all() -> Vec<Box<dyn Module>> {
Shipped modules: **`builtins`** (`ping`, `echo`, `hello`), **`dice`** Shipped modules: **`builtins`** (`ping`, `echo`, `hello`), **`dice`**
(`roll [NdM]`), **`hash`** (`md5`/`sha1`/`sha256`/`hash <text>`, hand-rolled), (`roll [NdM]`), **`hash`** (`md5`/`sha1`/`sha256`/`hash <text>`, hand-rolled),
and **`weather`** (`add <location>` then `w [location]` via wttr.in; per-user **`weather`** (`add <location>` then `w [location]` via wttr.in; per-user
locations saved to a per-network JSON file, path from `RUSTBOT_DATA_DIR`). locations saved to a per-network JSON file, path from `RUSTBOT_DATA_DIR`), and
Module tests live in `tests/` — construct a module and assert on the `Action`s web lookups over the shared `http`/`json` helpers: **`crypto <sym>`** (Bitstamp),
it returns, no network required. **`wiki <term>`**, **`define <word>`**, **`urban <term>`**, **`tinyurl <url>`**,
**`down <host>`**. 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 ## Build & run

63
src/bot/modules/crypto.rs Normal file
View file

@ -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 <symbol>",
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<Action> {
let Some(sym) = cmd.args.first() else {
return vec![Action::reply(format!(
"usage: {}crypto <symbol>",
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::<f64>().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}"),
}
}

73
src/bot/modules/define.rs Normal file
View file

@ -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 <word>",
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<Action> {
let Some(word) = cmd.args.first() else {
return vec![Action::reply(format!("usage: {}define <word>", 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::<String>();
out.push('…');
}
out
}
None => format!("no definition for '{word}'"),
}
}

64
src/bot/modules/down.rs Normal file
View file

@ -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 <host>",
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<Action> {
let Some(arg) = cmd.args.first() else {
return vec![Action::reply(format!("usage: {}down <host>", cmd.prefix))];
};
let host = host_of(arg);
if host.is_empty() {
return vec![Action::reply(format!("usage: {}down <host>", 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()
}

View file

@ -1,7 +1,13 @@
pub mod builtins; pub mod builtins;
pub mod crypto;
pub mod define;
pub mod dice; pub mod dice;
pub mod down;
pub mod hash; pub mod hash;
pub mod tinyurl;
pub mod urban;
pub mod weather; pub mod weather;
pub mod wiki;
use super::module::Module; use super::module::Module;
@ -10,6 +16,12 @@ pub fn all(network: &str) -> Vec<Box<dyn Module>> {
Box::new(builtins::Builtins), Box::new(builtins::Builtins),
Box::new(dice::Dice::new()), Box::new(dice::Dice::new()),
Box::new(hash::Hash), 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(weather::Weather::new(network)),
Box::new(wiki::Wiki),
] ]
} }

View file

@ -0,0 +1,37 @@
use crate::bot::module::{Action, Command, CommandSpec, Module};
pub struct Tinyurl;
const COMMANDS: &[CommandSpec] = &[CommandSpec {
name: "tinyurl",
usage: "tinyurl <url>",
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<Action> {
let Some(url) = cmd.args.first() else {
return vec![Action::reply(format!("usage: {}tinyurl <url>", 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}"))],
}
}
}

58
src/bot/modules/urban.rs Normal file
View file

@ -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 <term>",
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<Action> {
let term = cmd.args.join(" ");
if term.is_empty() {
return vec![Action::reply(format!("usage: {}urban <term>", 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::<String>();
out.push('…');
}
out
}
None => format!("no results for '{term}'"),
}
}

View file

@ -1,14 +1,11 @@
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use std::fs; use std::fs;
use std::io::{self, Read, Write}; use std::io;
use std::iter::Peekable; use std::iter::Peekable;
use std::net::{TcpStream, ToSocketAddrs};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::str::Chars; use std::str::Chars;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use native_tls::TlsConnector;
use crate::bot::module::{Action, Command, CommandSpec, Module}; use crate::bot::module::{Action, Command, CommandSpec, Module};
const CACHE_TTL: Duration = Duration::from_secs(600); const CACHE_TTL: Duration = Duration::from_secs(600);
@ -148,34 +145,20 @@ impl Store {
} }
fn fetch_weather(location: &str) -> Result<String, String> { fn fetch_weather(location: &str) -> Result<String, String> {
let path = format!("/{}?format=%l|%c|%C|%t|%f|%w|%h&m", url_encode(location)); let path = format!(
let mut last = "no response".to_string(); "/{}?format=%l|%c|%C|%t|%f|%w|%h&m",
for attempt in 0..3 { crate::http::url_encode(location)
if attempt > 0 { );
std::thread::sleep(Duration::from_millis(250)); let body = crate::http::get("wttr.in", &path)?;
} let raw = body
match https_get("wttr.in", &path) { .lines()
Ok(body) => { .map(str::trim)
let raw = body .find(|l| !l.is_empty())
.lines() .unwrap_or("");
.map(str::trim) if raw.is_empty() {
.find(|l| !l.is_empty()) return Err("no data".to_string());
.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;
}
}
}
} }
Err(last) Ok(format_weather(raw, location))
} }
pub fn format_weather(raw: &str, location: &str) -> String { 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() raw.chars().take(250).collect()
} }
struct HttpErr {
msg: String,
retry: bool,
}
fn https_get(host: &str, path: &str) -> Result<String, HttpErr> {
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::<u16>().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, String>) -> String { pub fn to_json(map: &BTreeMap<String, String>) -> String {
let items: Vec<String> = map let items: Vec<String> = map
.iter() .iter()

72
src/bot/modules/wiki.rs Normal file
View file

@ -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 <term>",
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<Action> {
let term = cmd.args.join(" ");
if term.is_empty() {
return vec![Action::reply(format!("usage: {}wiki <term>", 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<String> {
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::<String>();
out.push('…');
}
out
}

120
src/http.rs Normal file
View file

@ -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<String, String> {
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<String, HttpErr> {
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::<u16>().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
)
}

184
src/json.rs Normal file
View file

@ -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<Json>),
Obj(Vec<(String, Json)>),
}
impl Json {
pub fn parse(s: &str) -> Option<Json> {
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<f64> {
match self {
Json::Num(n) => Some(*n),
_ => None,
}
}
}
struct Parser<'a> {
it: Peekable<Chars<'a>>,
}
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<Json> {
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<Json> {
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<Json> {
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<String> {
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<Json> {
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::<f64>().ok().map(Json::Num)
}
fn boolean(&mut self) -> Option<Json> {
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
}
}

View file

@ -2,7 +2,9 @@
pub mod bot; pub mod bot;
pub mod config; pub mod config;
pub mod http;
pub mod irc; pub mod irc;
pub mod json;
pub fn log(msg: &str) { pub fn log(msg: &str) {
eprintln!("[rustbot] {msg}"); eprintln!("[rustbot] {msg}");

38
tests/json.rs Normal file
View file

@ -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\""));
}
#[test]
fn rejects_garbage() {
assert!(Json::parse("{oops").is_none());
assert!(Json::parse("").is_none());
assert!(Json::parse("[1,2").is_none());
}

50
tests/tier4.rs Normal file
View file

@ -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");
}