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`**
|
||||
(`roll [NdM]`), **`hash`** (`md5`/`sha1`/`sha256`/`hash <text>`, hand-rolled),
|
||||
and **`weather`** (`add <location>` then `w [location]` via wttr.in; per-user
|
||||
locations saved to a per-network JSON file, path from `RUSTBOT_DATA_DIR`).
|
||||
Module tests live in `tests/` — construct a module and assert on the `Action`s
|
||||
it returns, no network required.
|
||||
**`weather`** (`add <location>` then `w [location]` via wttr.in; per-user
|
||||
locations saved to a per-network JSON file, path from `RUSTBOT_DATA_DIR`), and
|
||||
web lookups over the shared `http`/`json` helpers: **`crypto <sym>`** (Bitstamp),
|
||||
**`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
|
||||
|
||||
|
|
|
|||
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 crypto;
|
||||
pub mod define;
|
||||
pub mod dice;
|
||||
pub mod down;
|
||||
pub mod hash;
|
||||
pub mod tinyurl;
|
||||
pub mod urban;
|
||||
pub mod weather;
|
||||
pub mod wiki;
|
||||
|
||||
use super::module::Module;
|
||||
|
||||
|
|
@ -10,6 +16,12 @@ pub fn all(network: &str) -> Vec<Box<dyn Module>> {
|
|||
Box::new(builtins::Builtins),
|
||||
Box::new(dice::Dice::new()),
|
||||
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(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::fs;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::io;
|
||||
use std::iter::Peekable;
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::Chars;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use native_tls::TlsConnector;
|
||||
|
||||
use crate::bot::module::{Action, Command, CommandSpec, Module};
|
||||
|
||||
const CACHE_TTL: Duration = Duration::from_secs(600);
|
||||
|
|
@ -148,34 +145,20 @@ impl Store {
|
|||
}
|
||||
|
||||
fn fetch_weather(location: &str) -> Result<String, String> {
|
||||
let path = format!("/{}?format=%l|%c|%C|%t|%f|%w|%h&m", url_encode(location));
|
||||
let mut last = "no response".to_string();
|
||||
for attempt in 0..3 {
|
||||
if attempt > 0 {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
match https_get("wttr.in", &path) {
|
||||
Ok(body) => {
|
||||
let path = format!(
|
||||
"/{}?format=%l|%c|%C|%t|%f|%w|%h&m",
|
||||
crate::http::url_encode(location)
|
||||
);
|
||||
let body = crate::http::get("wttr.in", &path)?;
|
||||
let raw = body
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty())
|
||||
.unwrap_or("");
|
||||
if !raw.is_empty() {
|
||||
return Ok(format_weather(raw, location));
|
||||
if raw.is_empty() {
|
||||
return Err("no data".to_string());
|
||||
}
|
||||
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 {
|
||||
|
|
@ -196,101 +179,6 @@ pub fn format_weather(raw: &str, location: &str) -> String {
|
|||
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 {
|
||||
let items: Vec<String> = map
|
||||
.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 config;
|
||||
pub mod http;
|
||||
pub mod irc;
|
||||
pub mod json;
|
||||
|
||||
pub fn log(msg: &str) {
|
||||
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