diff --git a/README.md b/README.md index 357687f..9330352 100644 --- a/README.md +++ b/README.md @@ -70,12 +70,18 @@ lists them at runtime and `help ` describes one. | `roll [NdM]` | roll dice (default `1d6`) | | `karma [thing]` / `karmabottom` / `karmaweb` (bump with `thing++ [reason]` / `thing--`) | karma + rank + last reason, or top/bottom (with a link to the web board when `karma_url` is set); `karmaweb` prints that link; milestones announced | | `md5` / `sha1` / `sha256` / `hash ` | hex digests | -| `add ` then `w [location]` | weather (wttr.in), per-user location | -| `crypto ` | coin price in USD (Bitstamp) | +| `add ` then `w [location]` / `fc [location]` | weather + 3-day forecast (wttr.in), per-user location | +| `crypto ` / `coins` | coin price in USD (Bitstamp) / top-10 by market cap (CoinGecko) | | `wiki ` | Wikipedia summary | | `define ` / `urban ` | dictionary / Urban Dictionary | +| `dict` / `definitions` / `thes` / `jargon` / `term` / `acronym` / `element` / `bible` / `biblename` / `law` `` | DICT (RFC 2229) lookups on dict.org โ€” WordNet, GCIDE, Moby thesaurus, Jargon File, FOLDOC, VERA, elements, Easton/Hitchcock, Bouvier | | `tinyurl ` | shorten a URL | | `down ` | is a site reachable? | +| `worldtime ` | current time in a timezone | +| `8ball ` / `rock` / `paper` / `scissors` | magic 8-ball, rock-paper-scissors | +| `joke` / `fact` / `catfact` / `advice` / `catgif` | random fun (keyless APIs) | +| `bofh` / `devexcuse` / `chuck` / `startup` / `buzz` | excuse & buzzword generators | +| `coffee [nick]` / `ty [nick]` / `drinks` | social actions | ## Karma web page diff --git a/src/bot/modules/crypto.rs b/src/bot/modules/crypto.rs index 16b67ab..2dc4406 100644 --- a/src/bot/modules/crypto.rs +++ b/src/bot/modules/crypto.rs @@ -3,11 +3,18 @@ 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", -}]; +const COMMANDS: &[CommandSpec] = &[ + CommandSpec { + name: "crypto", + usage: "crypto ", + about: "USD price of a coin (e.g. crypto btc), via Bitstamp", + }, + CommandSpec { + name: "coins", + usage: "coins", + about: "top 10 coins by market cap (CoinGecko)", + }, +]; impl Module for Crypto { fn name(&self) -> &'static str { @@ -19,6 +26,14 @@ impl Module for Crypto { } fn on_command(&mut self, cmd: &Command) -> Vec { + if cmd.name == "coins" { + let path = + "/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=10&page=1"; + return match crate::http::get("api.coingecko.com", path) { + Ok(body) => vec![Action::reply(format_coins(&body))], + Err(e) => vec![Action::reply(format!("coins unavailable: {e}"))], + }; + } let Some(sym) = cmd.args.first() else { return vec![Action::reply(format!( "usage: {}crypto ", @@ -61,3 +76,33 @@ pub fn format_ticker(sym: &str, body: &str) -> String { _ => format!("no data for {up}"), } } + +pub fn format_coins(body: &str) -> String { + let Some(Json::Arr(items)) = Json::parse(body) else { + return "no data".to_string(); + }; + let parts: Vec = items + .iter() + .take(10) + .filter_map(|c| { + let sym = c.get("symbol").and_then(Json::as_str)?.to_uppercase(); + let price = c.get("current_price").and_then(Json::as_f64)?; + Some(format!("{sym} ${}", fmt_price(price))) + }) + .collect(); + if parts.is_empty() { + "no data".to_string() + } else { + format!("top coins: {}", parts.join(", ")) + } +} + +fn fmt_price(p: f64) -> String { + if p >= 1000.0 { + format!("{p:.0}") + } else if p >= 1.0 { + format!("{p:.2}") + } else { + format!("{p:.4}") + } +} diff --git a/src/bot/modules/dict.rs b/src/bot/modules/dict.rs new file mode 100644 index 0000000..54ed141 --- /dev/null +++ b/src/bot/modules/dict.rs @@ -0,0 +1,180 @@ +use std::io::{Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; + +use crate::bot::module::{Action, Command, CommandSpec, Module}; + +pub struct Dict; + +const COMMANDS: &[CommandSpec] = &[ + CommandSpec { + name: "dict", + usage: "dict ", + about: "WordNet definition", + }, + CommandSpec { + name: "definitions", + usage: "definitions ", + about: "GCIDE dictionary definition", + }, + CommandSpec { + name: "thes", + usage: "thes ", + about: "Moby thesaurus synonyms", + }, + CommandSpec { + name: "jargon", + usage: "jargon ", + about: "Jargon File lookup", + }, + CommandSpec { + name: "term", + usage: "term ", + about: "computing term (FOLDOC)", + }, + CommandSpec { + name: "acronym", + usage: "acronym ", + about: "acronym expansion (VERA)", + }, + CommandSpec { + name: "element", + usage: "element ", + about: "chemical element info", + }, + CommandSpec { + name: "bible", + usage: "bible ", + about: "Easton's Bible Dictionary", + }, + CommandSpec { + name: "biblename", + usage: "biblename ", + about: "meaning of a Biblical name", + }, + CommandSpec { + name: "law", + usage: "law ", + about: "Bouvier's Law Dictionary", + }, +]; + +fn db_for(command: &str) -> Option<&'static str> { + Some(match command { + "dict" => "wn", + "definitions" => "gcide", + "thes" => "moby-thesaurus", + "jargon" => "jargon", + "term" => "foldoc", + "acronym" => "vera", + "element" => "elements", + "bible" => "easton", + "biblename" => "hitchcock", + "law" => "bouvier", + _ => return None, + }) +} + +impl Module for Dict { + fn name(&self) -> &'static str { + "dict" + } + + fn commands(&self) -> &'static [CommandSpec] { + COMMANDS + } + + fn on_command(&mut self, cmd: &Command) -> Vec { + let Some(db) = db_for(cmd.name) else { + return vec![]; + }; + if cmd.args.is_empty() { + return vec![Action::reply(format!( + "usage: {}{} ", + cmd.prefix, cmd.name + ))]; + } + let word = clean(&cmd.args.join(" ")); + if word.is_empty() { + return vec![Action::reply("invalid term".to_string())]; + } + let reply = match lookup(db, &word) { + Ok(Some(def)) => format!("{word}: {def}"), + Ok(None) => format!("no entry for '{word}'"), + Err(e) => format!("dict unavailable: {e}"), + }; + vec![Action::reply(reply)] + } +} + +fn clean(s: &str) -> String { + s.chars() + .filter(|c| !c.is_control() && *c != '"') + .take(64) + .collect::() + .trim() + .to_string() +} + +fn lookup(db: &str, word: &str) -> Result, String> { + let addr = ("dict.org", 2628) + .to_socket_addrs() + .map_err(|e| e.to_string())? + .next() + .ok_or_else(|| "dns lookup failed".to_string())?; + let mut stream = + TcpStream::connect_timeout(&addr, Duration::from_secs(4)).map_err(|e| e.to_string())?; + stream.set_read_timeout(Some(Duration::from_secs(6))).ok(); + stream.set_write_timeout(Some(Duration::from_secs(6))).ok(); + let req = format!("DEFINE {db} \"{word}\"\r\nQUIT\r\n"); + stream + .write_all(req.as_bytes()) + .map_err(|e| e.to_string())?; + let mut buf = Vec::new(); + stream.read_to_end(&mut buf).map_err(|e| e.to_string())?; + Ok(parse_definition(&String::from_utf8_lossy(&buf))) +} + +/// Extract the first definition body from a DICT (RFC 2229) response. +pub fn parse_definition(text: &str) -> Option { + let mut lines = text.lines(); + let mut found = false; + for l in lines.by_ref() { + if l.starts_with("151") { + found = true; + break; + } + if l.starts_with("552") || l.starts_with("550") || l.starts_with("501") { + return None; + } + } + if !found { + return None; + } + let mut body = String::new(); + for l in lines.by_ref() { + if l.trim_end() == "." { + break; + } + let t = l.trim(); + if t.is_empty() { + continue; + } + if !body.is_empty() { + body.push(' '); + } + body.push_str(t); + } + let body: String = body + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .filter(|c| !matches!(c, '{' | '}')) + .collect(); + if body.is_empty() { + None + } else { + Some(body.chars().take(400).collect()) + } +} diff --git a/src/bot/modules/fun.rs b/src/bot/modules/fun.rs new file mode 100644 index 0000000..69ceb20 --- /dev/null +++ b/src/bot/modules/fun.rs @@ -0,0 +1,289 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::bot::module::{Action, Command, CommandSpec, Module}; + +const COMMANDS: &[CommandSpec] = &[ + CommandSpec { + name: "8ball", + usage: "8ball ", + about: "ask the magic 8-ball a yes/no question", + }, + CommandSpec { + name: "rock", + usage: "rock", + about: "play rock-paper-scissors (also: paper, scissors)", + }, + CommandSpec { + name: "paper", + usage: "paper", + about: "play rock-paper-scissors", + }, + CommandSpec { + name: "scissors", + usage: "scissors", + about: "play rock-paper-scissors", + }, + CommandSpec { + name: "coffee", + usage: "coffee [nick]", + about: "serve someone a coffee", + }, + CommandSpec { + name: "ty", + usage: "ty [nick]", + about: "thank someone", + }, + CommandSpec { + name: "drinks", + usage: "drinks", + about: "what's on the menu", + }, + CommandSpec { + name: "catgif", + usage: "catgif", + about: "a random cat picture", + }, + CommandSpec { + name: "bofh", + usage: "bofh", + about: "a BOFH-style excuse", + }, + CommandSpec { + name: "devexcuse", + usage: "devexcuse", + about: "a developer's excuse", + }, + CommandSpec { + name: "chuck", + usage: "chuck", + about: "a Chuck Norris fact", + }, + CommandSpec { + name: "startup", + usage: "startup", + about: "generate a startup pitch", + }, + CommandSpec { + name: "buzz", + usage: "buzz", + about: "generate corporate buzz", + }, +]; + +const EIGHTBALL: &[&str] = &[ + "It is certain.", + "It is decidedly so.", + "Without a doubt.", + "Yes, definitely.", + "You may rely on it.", + "As I see it, yes.", + "Most likely.", + "Outlook good.", + "Yes.", + "Signs point to yes.", + "Reply hazy, try again.", + "Ask again later.", + "Better not tell you now.", + "Cannot predict now.", + "Concentrate and ask again.", + "Don't count on it.", + "My reply is no.", + "My sources say no.", + "Outlook not so good.", + "Very doubtful.", +]; + +const BOFH: &[&str] = &[ + "clock speed", + "solar flares", + "static from nylon underwear", + "the Doppler effect", + "hardware stress fractures", + "magnetic interference from money in your pocket", + "dry joints on the cable plug", + "we're out of slots on the server", + "the cables are fine, they're just the wrong colour", + "root rot", + "a fatal error right in front of the screen", + "excessive quantum tunnelling", +]; + +const DEVEXCUSE: &[&str] = &[ + "it works on my machine", + "that's not a bug, it's a feature", + "it was working yesterday", + "must be a caching issue", + "the requirements changed", + "it's a race condition", + "there's a typo somewhere", + "the compiler optimised it wrong", + "it's floating-point rounding", + "someone else touched that code", + "that's an edge case nobody hits", + "it's not in scope for this sprint", +]; + +const CHUCK: &[&str] = &[ + "Chuck Norris can divide by zero.", + "Chuck Norris counted to infinity. Twice.", + "Chuck Norris's code never has bugs; bugs have Chuck Norris.", + "When Chuck Norris throws an exception, it lands across the room.", + "Chuck Norris can compile syntax errors.", + "Chuck Norris deletes the Recycle Bin.", + "Chuck Norris unit-tests entire apps with a single assert.", + "Chuck Norris doesn't debug; he stares the bug down.", + "Chuck Norris's keyboard has no Esc key โ€” there is no escape.", + "Chuck Norris can access private methods.", +]; + +const STARTUP_A: &[&str] = &["Uber", "Airbnb", "Netflix", "Tinder", "Slack", "Spotify"]; +const STARTUP_B: &[&str] = &[ + "cats", + "gravestones", + "houseplants", + "left socks", + "IRC bots", + "sourdough", +]; +const BUZZ_VERB: &[&str] = &[ + "synergise", + "leverage", + "disrupt", + "monetise", + "gamify", + "streamline", + "architect", + "evangelise", +]; +const BUZZ_ADJ: &[&str] = &[ + "scalable", + "frictionless", + "agile", + "cloud-native", + "blockchain-enabled", + "AI-driven", + "holistic", + "next-gen", +]; +const BUZZ_NOUN: &[&str] = &[ + "paradigms", + "synergies", + "ecosystems", + "deliverables", + "workflows", + "verticals", + "touchpoints", + "solutions", +]; + +pub struct Fun { + rng: u64, +} + +impl Fun { + pub fn new() -> Fun { + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0x9E37_79B9_7F4A_7C15); + Fun { rng: seed | 1 } + } + + fn next(&mut self) -> u64 { + let mut x = self.rng; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.rng = x; + x + } + + fn pick<'a>(&mut self, xs: &[&'a str]) -> &'a str { + xs[(self.next() % xs.len() as u64) as usize] + } +} + +impl Default for Fun { + fn default() -> Fun { + Fun::new() + } +} + +impl Module for Fun { + fn name(&self) -> &'static str { + "fun" + } + + fn commands(&self) -> &'static [CommandSpec] { + COMMANDS + } + + fn on_command(&mut self, cmd: &Command) -> Vec { + let reply = match cmd.name { + "8ball" => { + if cmd.args.is_empty() { + "๐ŸŽฑ ask me a yes/no question".to_string() + } else { + format!("๐ŸŽฑ {}", self.pick(EIGHTBALL)) + } + } + "rock" | "paper" | "scissors" => return vec![Action::reply(self.rps(cmd.name))], + "coffee" => { + let who = cmd.args.first().copied().unwrap_or(cmd.sender); + return vec![action(cmd.reply_to, &format!("slides {who} a fresh coffee โ˜•"))]; + } + "ty" => { + let who = cmd.args.first().copied().unwrap_or(cmd.sender); + return vec![action(cmd.reply_to, &format!("thanks {who} ๐Ÿ™"))]; + } + "drinks" => { + "on the menu: โ˜• coffee ยท ๐Ÿต tea ยท ๐Ÿง‰ mate ยท ๐Ÿบ beer ยท ๐Ÿฅค soda ยท ๐Ÿท wine ยท ๐Ÿฅƒ whisky" + .to_string() + } + "catgif" => format!("๐Ÿฑ https://cataas.com/cat?{}", self.next()), + "bofh" => format!("BOFH excuse: {}", self.pick(BOFH)), + "devexcuse" => format!("it's not a bug โ€” {}", self.pick(DEVEXCUSE)), + "chuck" => self.pick(CHUCK).to_string(), + "startup" => format!( + "we're the {} for {} ๐Ÿš€", + self.pick(STARTUP_A), + self.pick(STARTUP_B) + ), + "buzz" => format!( + "{} {} {}", + self.pick(BUZZ_VERB), + self.pick(BUZZ_ADJ), + self.pick(BUZZ_NOUN) + ), + _ => return vec![], + }; + vec![Action::reply(reply)] + } +} + +impl Fun { + fn rps(&mut self, choice: &str) -> String { + let names = ["rock", "paper", "scissors"]; + let icons = ["๐Ÿชจ", "๐Ÿ“„", "โœ‚๏ธ"]; + let user = match choice { + "rock" => 0usize, + "paper" => 1, + _ => 2, + }; + let bot = (self.next() % 3) as usize; + let outcome = match (user + 3 - bot) % 3 { + 0 => "it's a tie", + 1 => "you win! ๐ŸŽ‰", + _ => "I win ๐Ÿ˜Ž", + }; + format!( + "you: {} {} ยท me: {} {} โ€” {outcome}", + names[user], icons[user], names[bot], icons[bot] + ) + } +} + +/// A CTCP ACTION (`/me โ€ฆ`) aimed at the reply target. +fn action(reply_to: &str, text: &str) -> Action { + Action::Raw(format!("PRIVMSG {reply_to} :\u{1}ACTION {text}\u{1}")) +} diff --git a/src/bot/modules/funweb.rs b/src/bot/modules/funweb.rs new file mode 100644 index 0000000..9bb4d26 --- /dev/null +++ b/src/bot/modules/funweb.rs @@ -0,0 +1,149 @@ +use crate::bot::module::{Action, Command, CommandSpec, Module}; +use crate::json::Json; + +pub struct Funweb; + +const COMMANDS: &[CommandSpec] = &[ + CommandSpec { + name: "joke", + usage: "joke", + about: "a random (safe) joke", + }, + CommandSpec { + name: "fact", + usage: "fact", + about: "a random useless fact", + }, + CommandSpec { + name: "catfact", + usage: "catfact", + about: "a random cat fact", + }, + CommandSpec { + name: "advice", + usage: "advice", + about: "a slip of advice", + }, + CommandSpec { + name: "worldtime", + usage: "worldtime ", + about: "current time in a timezone, e.g. Europe/Paris", + }, +]; + +impl Module for Funweb { + fn name(&self) -> &'static str { + "funweb" + } + + fn commands(&self) -> &'static [CommandSpec] { + COMMANDS + } + + fn on_command(&mut self, cmd: &Command) -> Vec { + let reply = match cmd.name { + "joke" => fetch( + "v2.jokeapi.dev", + "/joke/Any?safe-mode&type=single", + format_joke, + ), + "fact" => fetch( + "uselessfacts.jsph.pl", + "/api/v2/facts/random?language=en", + format_fact, + ), + "catfact" => fetch("catfact.ninja", "/fact", format_catfact), + "advice" => fetch("api.adviceslip.com", "/advice", format_advice), + "worldtime" => { + let Some(tz) = cmd.args.first().map(|s| sanitize_tz(s)) else { + return vec![Action::reply(format!( + "usage: {}worldtime Area/City (e.g. Europe/Paris)", + cmd.prefix + ))]; + }; + if tz.is_empty() { + return vec![Action::reply("invalid timezone".to_string())]; + } + match crate::http::get( + "timeapi.io", + &format!("/api/time/current/zone?timeZone={tz}"), + ) { + Ok(body) => format_worldtime(&tz, &body), + Err(e) if e.contains("400") || e.contains("404") => { + format!("unknown timezone '{tz}' (try Area/City, e.g. Europe/Paris)") + } + Err(e) => format!("time unavailable: {e}"), + } + } + _ => return vec![], + }; + vec![Action::reply(reply)] + } +} + +fn fetch(host: &str, path: &str, fmt: fn(&str) -> String) -> String { + match crate::http::get(host, path) { + Ok(body) => fmt(&body), + Err(e) => format!("unavailable: {e}"), + } +} + +fn sanitize_tz(s: &str) -> String { + s.chars() + .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '-' | '+')) + .take(48) + .collect() +} + +pub fn format_joke(body: &str) -> String { + let Some(j) = Json::parse(body) else { + return "no joke right now".to_string(); + }; + if let Some(joke) = j.get("joke").and_then(Json::as_str) { + return joke.to_string(); + } + match ( + j.get("setup").and_then(Json::as_str), + j.get("delivery").and_then(Json::as_str), + ) { + (Some(s), Some(d)) => format!("{s} โ€ฆ {d}"), + _ => "no joke right now".to_string(), + } +} + +pub fn format_fact(body: &str) -> String { + Json::parse(body) + .and_then(|j| j.get("text").and_then(Json::as_str).map(str::to_string)) + .unwrap_or_else(|| "no fact right now".to_string()) +} + +pub fn format_catfact(body: &str) -> String { + match Json::parse(body).and_then(|j| j.get("fact").and_then(Json::as_str).map(str::to_string)) { + Some(f) => format!("๐Ÿฑ {f}"), + None => "no cat fact right now".to_string(), + } +} + +pub fn format_advice(body: &str) -> String { + match Json::parse(body).and_then(|j| { + j.get("slip")? + .get("advice") + .and_then(Json::as_str) + .map(str::to_string) + }) { + Some(a) => format!("๐Ÿ’ก {a}"), + None => "no advice right now".to_string(), + } +} + +pub fn format_worldtime(tz: &str, body: &str) -> String { + let Some(j) = Json::parse(body) else { + return format!("no time for '{tz}'"); + }; + let time = j.get("time").and_then(Json::as_str).unwrap_or(""); + let day = j.get("dayOfWeek").and_then(Json::as_str).unwrap_or(""); + if time.is_empty() { + return format!("no time for '{tz}'"); + } + format!("๐Ÿ•’ {tz}: {time} {day}").trim_end().to_string() +} diff --git a/src/bot/modules/mod.rs b/src/bot/modules/mod.rs index 79ef314..2754072 100644 --- a/src/bot/modules/mod.rs +++ b/src/bot/modules/mod.rs @@ -2,7 +2,10 @@ pub mod builtins; pub mod crypto; pub mod define; pub mod dice; +pub mod dict; pub mod down; +pub mod fun; +pub mod funweb; pub mod hash; pub mod karma; pub mod tinyurl; @@ -22,6 +25,9 @@ pub fn all(network: &str, locale: Locale, karma_url: Option) -> Vec vec![Action::reply(format!("weather unavailable: {e}"))], } } + "fc" => { + 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_forecast(&loc) { + Ok(text) => vec![Action::reply(text)], + Err(e) => vec![Action::reply(format!("forecast unavailable: {e}"))], + } + } _ => vec![], } } } +fn fetch_forecast(location: &str) -> Result { + let path = format!("/{}?format=j1", crate::http::url_encode(location)); + let body = crate::http::get("wttr.in", &path)?; + Ok(format_forecast(&body, location)) +} + +pub fn format_forecast(body: &str, location: &str) -> String { + let Some(j) = Json::parse(body) else { + return format!("no forecast for '{location}'"); + }; + let mut days = Vec::new(); + for i in 0..3 { + let Some(d) = j.get("weather").and_then(|w| w.idx(i)) else { + break; + }; + let date = d.get("date").and_then(Json::as_str).unwrap_or(""); + let mx = d.get("maxtempC").and_then(Json::as_str).unwrap_or("?"); + let mn = d.get("mintempC").and_then(Json::as_str).unwrap_or("?"); + let desc = d + .get("hourly") + .and_then(|h| h.idx(4)) + .and_then(|m| m.get("weatherDesc")) + .and_then(|w| w.idx(0)) + .and_then(|v| v.get("value")) + .and_then(Json::as_str) + .unwrap_or("") + .trim(); + let short = date.get(5..).unwrap_or(date); + if desc.is_empty() { + days.push(format!("{short} {mx}/{mn}ยฐC")); + } else { + days.push(format!("{short} {desc} {mx}/{mn}ยฐC")); + } + } + if days.is_empty() { + return format!("no forecast for '{location}'"); + } + format!("{location}: {}", days.join(" ยท ")) + .chars() + .take(300) + .collect() +} + struct Store { map: BTreeMap, } diff --git a/tests/skybot.rs b/tests/skybot.rs new file mode 100644 index 0000000..1c351a4 --- /dev/null +++ b/tests/skybot.rs @@ -0,0 +1,127 @@ +use rustbot::bot::module::{Action, Command, Module}; +use rustbot::bot::modules::crypto::format_coins; +use rustbot::bot::modules::dict::parse_definition; +use rustbot::bot::modules::fun::Fun; +use rustbot::bot::modules::funweb::{ + format_advice, format_catfact, format_fact, format_joke, format_worldtime, +}; +use rustbot::bot::modules::weather::format_forecast; + +fn cmd<'a>(name: &'a str, args: &'a [&'a str]) -> Command<'a> { + Command { + sender: "u", + reply_to: "#c", + name, + args, + prefix: ">", + network: "t", + } +} + +// ---- funweb formatters ---- + +#[test] +fn joke_single_and_twopart() { + assert_eq!( + format_joke(r#"{"type":"single","joke":"UDP joke."}"#), + "UDP joke." + ); + assert_eq!( + format_joke(r#"{"type":"twopart","setup":"knock knock","delivery":"who?"}"#), + "knock knock โ€ฆ who?" + ); +} + +#[test] +fn fact_catfact_advice() { + assert_eq!( + format_fact(r#"{"text":"the moon is dry"}"#), + "the moon is dry" + ); + assert_eq!(format_catfact(r#"{"fact":"cats purr"}"#), "๐Ÿฑ cats purr"); + assert_eq!( + format_advice(r#"{"slip":{"id":1,"advice":"drink water"}}"#), + "๐Ÿ’ก drink water" + ); +} + +#[test] +fn worldtime_formats_time_and_day() { + let out = format_worldtime( + "Europe/Paris", + r#"{"time":"04:21","dayOfWeek":"Thursday","timeZone":"Europe/Paris"}"#, + ); + assert!(out.contains("Europe/Paris"), "{out}"); + assert!(out.contains("04:21") && out.contains("Thursday"), "{out}"); +} + +// ---- crypto: coins ---- + +#[test] +fn coins_lists_symbols_and_prices() { + let out = format_coins( + r#"[{"symbol":"btc","current_price":64183},{"symbol":"eth","current_price":3200},{"symbol":"xrp","current_price":0.5123}]"#, + ); + assert!(out.starts_with("top coins:"), "{out}"); + assert!(out.contains("BTC $64183"), "{out}"); + assert!(out.contains("ETH $3200"), "{out}"); + assert!(out.contains("XRP $0.5123"), "{out}"); // sub-$1 keeps 4 decimals +} + +// ---- weather: forecast ---- + +#[test] +fn forecast_summarizes_days() { + let body = r#"{"weather":[{"date":"2026-07-30","maxtempC":"34","mintempC":"22","hourly":[{},{},{},{},{"weatherDesc":[{"value":"Sunny"}]}]}]}"#; + let out = format_forecast(body, "Paris"); + assert!(out.starts_with("Paris:"), "{out}"); + assert!(out.contains("07-30"), "{out}"); + assert!(out.contains("Sunny") && out.contains("34/22"), "{out}"); +} + +// ---- dict: RFC 2229 parsing ---- + +#[test] +fn dict_parses_first_definition() { + let resp = "220 banner\r\n150 1 definitions retrieved\r\n151 \"cat\" wn \"WordNet\"\r\ncat\r\n n 1: feline mammal [syn: {cat}, {true cat}]\r\n.\r\n250 ok\r\n"; + let def = parse_definition(resp).expect("a definition"); + assert!(def.contains("feline mammal"), "{def}"); + assert!(!def.contains('{'), "braces should be stripped: {def}"); +} + +#[test] +fn dict_no_match_and_no_header_are_none() { + assert!(parse_definition("220 banner\r\n552 no match\r\n250 ok\r\n").is_none()); + assert!(parse_definition("220 banner\r\n250 ok\r\n").is_none()); +} + +// ---- fun (local) smoke tests ---- + +fn one_reply(actions: Vec) -> String { + match actions.as_slice() { + [Action::Reply(s)] => s.clone(), + _ => panic!("expected one reply"), + } +} + +#[test] +fn fun_basics() { + let mut f = Fun::new(); + assert!(one_reply(f.on_command(&cmd("8ball", &["will it work?"]))).contains("๐ŸŽฑ")); + assert!(one_reply(f.on_command(&cmd("drinks", &[]))).contains("coffee")); + assert!(one_reply(f.on_command(&cmd("catgif", &[]))).contains("cataas")); + let rps = one_reply(f.on_command(&cmd("rock", &[]))); + assert!(rps.contains("you:") && rps.contains("me:"), "{rps}"); +} + +#[test] +fn coffee_is_a_ctcp_action() { + let mut f = Fun::new(); + match f.on_command(&cmd("coffee", &["alice"])).as_slice() { + [Action::Raw(line)] => { + assert!(line.starts_with("PRIVMSG #c :\u{1}ACTION "), "{line}"); + assert!(line.contains("alice") && line.contains("coffee"), "{line}"); + } + _ => panic!("expected a raw ACTION"), + } +}