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
All checks were successful
ci / check (push) Successful in 45s
This commit is contained in:
parent
3c2154bbc1
commit
ae7d49dff8
8 changed files with 874 additions and 7 deletions
10
README.md
10
README.md
|
|
@ -70,12 +70,18 @@ lists them at runtime and `help <command>` 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 <text>` | hex digests |
|
||||
| `add <location>` then `w [location]` | weather (wttr.in), per-user location |
|
||||
| `crypto <sym>` | coin price in USD (Bitstamp) |
|
||||
| `add <location>` then `w [location]` / `fc [location]` | weather + 3-day forecast (wttr.in), per-user location |
|
||||
| `crypto <sym>` / `coins` | coin price in USD (Bitstamp) / top-10 by market cap (CoinGecko) |
|
||||
| `wiki <term>` | Wikipedia summary |
|
||||
| `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 |
|
||||
| `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
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,18 @@ 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",
|
||||
}];
|
||||
const COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec {
|
||||
name: "crypto",
|
||||
usage: "crypto <symbol>",
|
||||
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<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 {
|
||||
return vec![Action::reply(format!(
|
||||
"usage: {}crypto <symbol>",
|
||||
|
|
@ -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<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
180
src/bot/modules/dict.rs
Normal 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
289
src/bot/modules/fun.rs
Normal 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
149
src/bot/modules/funweb.rs
Normal 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()
|
||||
}
|
||||
|
|
@ -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<String>) -> Vec<Box<
|
|||
Box::new(crypto::Crypto),
|
||||
Box::new(define::Define),
|
||||
Box::new(down::Down),
|
||||
Box::new(fun::Fun::new()),
|
||||
Box::new(funweb::Funweb),
|
||||
Box::new(dict::Dict),
|
||||
Box::new(tinyurl::Tinyurl),
|
||||
Box::new(urban::Urban),
|
||||
Box::new(weather::Weather::new(network)),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use std::str::Chars;
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::bot::module::{Action, Command, CommandSpec, Module};
|
||||
use crate::json::Json;
|
||||
|
||||
const CACHE_TTL: Duration = Duration::from_secs(600);
|
||||
|
||||
|
|
@ -21,6 +22,11 @@ const COMMANDS: &[CommandSpec] = &[
|
|||
usage: "w [location]",
|
||||
about: "weather for your saved location",
|
||||
},
|
||||
CommandSpec {
|
||||
name: "fc",
|
||||
usage: "fc [location]",
|
||||
about: "3-day forecast",
|
||||
},
|
||||
];
|
||||
|
||||
pub struct Weather {
|
||||
|
|
@ -112,11 +118,70 @@ impl Module for Weather {
|
|||
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![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
map: BTreeMap<String, String>,
|
||||
}
|
||||
|
|
|
|||
127
tests/skybot.rs
Normal file
127
tests/skybot.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue