Add web modules (crypto, wiki, define, urban, tinyurl, down) + shared http/json
All checks were successful
ci / check (push) Successful in 48s
All checks were successful
ci / check (push) Successful in 48s
This commit is contained in:
parent
7846f91afe
commit
c3139b4e4d
14 changed files with 794 additions and 130 deletions
11
README.md
11
README.md
|
|
@ -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
63
src/bot/modules/crypto.rs
Normal 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
73
src/bot/modules/define.rs
Normal 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
64
src/bot/modules/down.rs
Normal 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()
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
src/bot/modules/tinyurl.rs
Normal file
37
src/bot/modules/tinyurl.rs
Normal 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
58
src/bot/modules/urban.rs
Normal 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}'"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
72
src/bot/modules/wiki.rs
Normal 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
120
src/http.rs
Normal 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
184
src/json.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
38
tests/json.rs
Normal 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\"bé"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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
50
tests/tier4.rs
Normal 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");
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue