modules: add fun (8ball/rps/generators/actions), funweb (joke/fact/catfact/advice/worldtime), dict (RFC 2229 lookups); crypto coins; weather fc
All checks were successful
ci / check (push) Successful in 45s

This commit is contained in:
Jean Chevronnet 2026-07-30 02:43:43 +00:00
parent 3c2154bbc1
commit ae7d49dff8
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
8 changed files with 874 additions and 7 deletions

View file

@ -70,12 +70,18 @@ lists them at runtime and `help <command>` describes one.
| `roll [NdM]` | roll dice (default `1d6`) | | `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 | | `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 <text>` | hex digests | | `md5` / `sha1` / `sha256` / `hash <text>` | hex digests |
| `add <location>` then `w [location]` | weather (wttr.in), per-user location | | `add <location>` then `w [location]` / `fc [location]` | weather + 3-day forecast (wttr.in), per-user location |
| `crypto <sym>` | coin price in USD (Bitstamp) | | `crypto <sym>` / `coins` | coin price in USD (Bitstamp) / top-10 by market cap (CoinGecko) |
| `wiki <term>` | Wikipedia summary | | `wiki <term>` | Wikipedia summary |
| `define <word>` / `urban <term>` | dictionary / Urban Dictionary | | `define <word>` / `urban <term>` | dictionary / Urban Dictionary |
| `dict` / `definitions` / `thes` / `jargon` / `term` / `acronym` / `element` / `bible` / `biblename` / `law` `<word>` | DICT (RFC 2229) lookups on dict.org — WordNet, GCIDE, Moby thesaurus, Jargon File, FOLDOC, VERA, elements, Easton/Hitchcock, Bouvier |
| `tinyurl <url>` | shorten a URL | | `tinyurl <url>` | shorten a URL |
| `down <host>` | is a site reachable? | | `down <host>` | is a site reachable? |
| `worldtime <Area/City>` | current time in a timezone |
| `8ball <question>` / `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 ## Karma web page

View file

@ -3,11 +3,18 @@ use crate::json::Json;
pub struct Crypto; pub struct Crypto;
const COMMANDS: &[CommandSpec] = &[CommandSpec { const COMMANDS: &[CommandSpec] = &[
CommandSpec {
name: "crypto", name: "crypto",
usage: "crypto <symbol>", usage: "crypto <symbol>",
about: "USD price of a coin (e.g. crypto btc), via Bitstamp", 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 { impl Module for Crypto {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
@ -19,6 +26,14 @@ impl Module for Crypto {
} }
fn on_command(&mut self, cmd: &Command) -> Vec<Action> { fn on_command(&mut self, cmd: &Command) -> Vec<Action> {
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 { let Some(sym) = cmd.args.first() else {
return vec![Action::reply(format!( return vec![Action::reply(format!(
"usage: {}crypto <symbol>", "usage: {}crypto <symbol>",
@ -61,3 +76,33 @@ pub fn format_ticker(sym: &str, body: &str) -> String {
_ => format!("no data for {up}"), _ => 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<String> = 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}")
}
}

180
src/bot/modules/dict.rs Normal file
View file

@ -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 <word>",
about: "WordNet definition",
},
CommandSpec {
name: "definitions",
usage: "definitions <word>",
about: "GCIDE dictionary definition",
},
CommandSpec {
name: "thes",
usage: "thes <word>",
about: "Moby thesaurus synonyms",
},
CommandSpec {
name: "jargon",
usage: "jargon <term>",
about: "Jargon File lookup",
},
CommandSpec {
name: "term",
usage: "term <term>",
about: "computing term (FOLDOC)",
},
CommandSpec {
name: "acronym",
usage: "acronym <abbr>",
about: "acronym expansion (VERA)",
},
CommandSpec {
name: "element",
usage: "element <name>",
about: "chemical element info",
},
CommandSpec {
name: "bible",
usage: "bible <term>",
about: "Easton's Bible Dictionary",
},
CommandSpec {
name: "biblename",
usage: "biblename <name>",
about: "meaning of a Biblical name",
},
CommandSpec {
name: "law",
usage: "law <term>",
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<Action> {
let Some(db) = db_for(cmd.name) else {
return vec![];
};
if cmd.args.is_empty() {
return vec![Action::reply(format!(
"usage: {}{} <term>",
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::<String>()
.trim()
.to_string()
}
fn lookup(db: &str, word: &str) -> Result<Option<String>, 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<String> {
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::<Vec<_>>()
.join(" ")
.chars()
.filter(|c| !matches!(c, '{' | '}'))
.collect();
if body.is_empty() {
None
} else {
Some(body.chars().take(400).collect())
}
}

289
src/bot/modules/fun.rs Normal file
View file

@ -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 <question>",
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<Action> {
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}"))
}

149
src/bot/modules/funweb.rs Normal file
View file

@ -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 <Area/City>",
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<Action> {
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()
}

View file

@ -2,7 +2,10 @@ pub mod builtins;
pub mod crypto; pub mod crypto;
pub mod define; pub mod define;
pub mod dice; pub mod dice;
pub mod dict;
pub mod down; pub mod down;
pub mod fun;
pub mod funweb;
pub mod hash; pub mod hash;
pub mod karma; pub mod karma;
pub mod tinyurl; pub mod tinyurl;
@ -22,6 +25,9 @@ pub fn all(network: &str, locale: Locale, karma_url: Option<String>) -> Vec<Box<
Box::new(crypto::Crypto), Box::new(crypto::Crypto),
Box::new(define::Define), Box::new(define::Define),
Box::new(down::Down), Box::new(down::Down),
Box::new(fun::Fun::new()),
Box::new(funweb::Funweb),
Box::new(dict::Dict),
Box::new(tinyurl::Tinyurl), Box::new(tinyurl::Tinyurl),
Box::new(urban::Urban), Box::new(urban::Urban),
Box::new(weather::Weather::new(network)), Box::new(weather::Weather::new(network)),

View file

@ -7,6 +7,7 @@ use std::str::Chars;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::bot::module::{Action, Command, CommandSpec, Module}; use crate::bot::module::{Action, Command, CommandSpec, Module};
use crate::json::Json;
const CACHE_TTL: Duration = Duration::from_secs(600); const CACHE_TTL: Duration = Duration::from_secs(600);
@ -21,6 +22,11 @@ const COMMANDS: &[CommandSpec] = &[
usage: "w [location]", usage: "w [location]",
about: "weather for your saved location", about: "weather for your saved location",
}, },
CommandSpec {
name: "fc",
usage: "fc [location]",
about: "3-day forecast",
},
]; ];
pub struct Weather { pub struct Weather {
@ -112,11 +118,70 @@ impl Module for Weather {
Err(e) => vec![Action::reply(format!("weather unavailable: {e}"))], Err(e) => 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 <location> 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![], _ => vec![],
} }
} }
} }
fn fetch_forecast(location: &str) -> Result<String, String> {
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 { struct Store {
map: BTreeMap<String, String>, map: BTreeMap<String, String>,
} }

127
tests/skybot.rs Normal file
View file

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