This commit is contained in:
parent
facbde55bd
commit
7846f91afe
11 changed files with 209 additions and 54 deletions
|
|
@ -8,7 +8,9 @@ const HISTORY_GRACE_SECS: u64 = 60;
|
|||
|
||||
impl Bot {
|
||||
pub(super) fn handle_batch(&mut self, msg: &Message) {
|
||||
let Some(reference) = msg.param(0) else { return };
|
||||
let Some(reference) = msg.param(0) else {
|
||||
return;
|
||||
};
|
||||
if let Some(name) = reference.strip_prefix('+') {
|
||||
self.batches
|
||||
.insert(name.to_string(), msg.param(1).unwrap_or("").to_string());
|
||||
|
|
@ -38,7 +40,9 @@ impl Bot {
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(target) = msg.param(0) else { return Ok(()) };
|
||||
let Some(target) = msg.param(0) else {
|
||||
return Ok(());
|
||||
};
|
||||
let text = msg.trailing().unwrap_or("");
|
||||
let sender = msg.nick().unwrap_or("");
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,21 @@ use crate::bot::module::{Action, Command, CommandSpec, Module};
|
|||
pub struct Builtins;
|
||||
|
||||
const COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec { name: "ping", usage: "ping", about: "reply with 'pong'" },
|
||||
CommandSpec { name: "echo", usage: "echo <text>", about: "echo the text back" },
|
||||
CommandSpec { name: "hello", usage: "hello", about: "greet the sender" },
|
||||
CommandSpec {
|
||||
name: "ping",
|
||||
usage: "ping",
|
||||
about: "reply with 'pong'",
|
||||
},
|
||||
CommandSpec {
|
||||
name: "echo",
|
||||
usage: "echo <text>",
|
||||
about: "echo the text back",
|
||||
},
|
||||
CommandSpec {
|
||||
name: "hello",
|
||||
usage: "hello",
|
||||
about: "greet the sender",
|
||||
},
|
||||
];
|
||||
|
||||
impl Module for Builtins {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,26 @@ use crate::bot::module::{Action, Command, CommandSpec, Module};
|
|||
pub struct Hash;
|
||||
|
||||
const COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec { name: "md5", usage: "md5 <text>", about: "MD5 hex digest of <text>" },
|
||||
CommandSpec { name: "sha1", usage: "sha1 <text>", about: "SHA1 hex digest of <text>" },
|
||||
CommandSpec { name: "sha256", usage: "sha256 <text>", about: "SHA256 hex digest of <text>" },
|
||||
CommandSpec { name: "hash", usage: "hash <text>", about: "md5, sha1 and sha256 of <text>" },
|
||||
CommandSpec {
|
||||
name: "md5",
|
||||
usage: "md5 <text>",
|
||||
about: "MD5 hex digest of <text>",
|
||||
},
|
||||
CommandSpec {
|
||||
name: "sha1",
|
||||
usage: "sha1 <text>",
|
||||
about: "SHA1 hex digest of <text>",
|
||||
},
|
||||
CommandSpec {
|
||||
name: "sha256",
|
||||
usage: "sha256 <text>",
|
||||
about: "SHA256 hex digest of <text>",
|
||||
},
|
||||
CommandSpec {
|
||||
name: "hash",
|
||||
usage: "hash <text>",
|
||||
about: "md5, sha1 and sha256 of <text>",
|
||||
},
|
||||
];
|
||||
|
||||
impl Module for Hash {
|
||||
|
|
@ -21,7 +37,10 @@ impl Module for Hash {
|
|||
fn on_command(&mut self, cmd: &Command) -> Vec<Action> {
|
||||
let input = cmd.args.join(" ");
|
||||
if input.is_empty() {
|
||||
return vec![Action::reply(format!("usage: {}{} <text>", cmd.prefix, cmd.name))];
|
||||
return vec![Action::reply(format!(
|
||||
"usage: {}{} <text>",
|
||||
cmd.prefix, cmd.name
|
||||
))];
|
||||
}
|
||||
let b = input.as_bytes();
|
||||
let reply = match cmd.name {
|
||||
|
|
|
|||
|
|
@ -14,8 +14,16 @@ use crate::bot::module::{Action, Command, CommandSpec, Module};
|
|||
const CACHE_TTL: Duration = Duration::from_secs(600);
|
||||
|
||||
const COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec { name: "add", usage: "add <location>", about: "save your weather location" },
|
||||
CommandSpec { name: "w", usage: "w [location]", about: "weather for your saved location" },
|
||||
CommandSpec {
|
||||
name: "add",
|
||||
usage: "add <location>",
|
||||
about: "save your weather location",
|
||||
},
|
||||
CommandSpec {
|
||||
name: "w",
|
||||
usage: "w [location]",
|
||||
about: "weather for your saved location",
|
||||
},
|
||||
];
|
||||
|
||||
pub struct Weather {
|
||||
|
|
@ -35,7 +43,11 @@ impl Weather {
|
|||
}
|
||||
|
||||
pub fn with_path(path: PathBuf) -> Weather {
|
||||
Weather { store: Store::load(&path), path, cache: HashMap::new() }
|
||||
Weather {
|
||||
store: Store::load(&path),
|
||||
path,
|
||||
cache: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn location_of(&self, nick: &str) -> Option<&str> {
|
||||
|
|
@ -56,15 +68,27 @@ impl Module for Weather {
|
|||
match cmd.name {
|
||||
"add" => {
|
||||
if cmd.args.is_empty() {
|
||||
return vec![Action::reply(format!("usage: {}add <location>", cmd.prefix))];
|
||||
return vec![Action::reply(format!(
|
||||
"usage: {}add <location>",
|
||||
cmd.prefix
|
||||
))];
|
||||
}
|
||||
let loc = cmd.args.join(" ");
|
||||
self.store.set(cmd.sender, &loc);
|
||||
if let Err(e) = self.store.save(&self.path) {
|
||||
crate::log(&format!("weather: could not save {}: {e}", self.path.display()));
|
||||
return vec![Action::reply(format!("{}: could not save location", cmd.sender))];
|
||||
crate::log(&format!(
|
||||
"weather: could not save {}: {e}",
|
||||
self.path.display()
|
||||
));
|
||||
return vec![Action::reply(format!(
|
||||
"{}: could not save location",
|
||||
cmd.sender
|
||||
))];
|
||||
}
|
||||
vec![Action::reply(format!("{}: saved {loc} — try {}w", cmd.sender, cmd.prefix))]
|
||||
vec![Action::reply(format!(
|
||||
"{}: saved {loc} — try {}w",
|
||||
cmd.sender, cmd.prefix
|
||||
))]
|
||||
}
|
||||
"w" => {
|
||||
let loc = if !cmd.args.is_empty() {
|
||||
|
|
@ -104,7 +128,9 @@ impl Store {
|
|||
fn load(path: &Path) -> Store {
|
||||
match fs::read_to_string(path) {
|
||||
Ok(s) => Store { map: parse_db(&s) },
|
||||
Err(_) => Store { map: BTreeMap::new() },
|
||||
Err(_) => Store {
|
||||
map: BTreeMap::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -130,7 +156,11 @@ fn fetch_weather(location: &str) -> Result<String, String> {
|
|||
}
|
||||
match https_get("wttr.in", &path) {
|
||||
Ok(body) => {
|
||||
let raw = body.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("");
|
||||
let raw = body
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty())
|
||||
.unwrap_or("");
|
||||
if !raw.is_empty() {
|
||||
return Ok(format_weather(raw, location));
|
||||
}
|
||||
|
|
@ -174,32 +204,52 @@ struct HttpErr {
|
|||
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 })?
|
||||
.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() })?;
|
||||
.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 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() })?;
|
||||
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() })?;
|
||||
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 });
|
||||
return Err(HttpErr {
|
||||
msg: "malformed response".to_string(),
|
||||
retry: true,
|
||||
});
|
||||
};
|
||||
let code = head
|
||||
.lines()
|
||||
|
|
@ -210,13 +260,18 @@ fn https_get(host: &str, path: &str) -> Result<String, HttpErr> {
|
|||
.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}") });
|
||||
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};
|
||||
use io::ErrorKind::{
|
||||
BrokenPipe, ConnectionAborted, ConnectionRefused, ConnectionReset, UnexpectedEof,
|
||||
};
|
||||
matches!(
|
||||
e.kind(),
|
||||
ConnectionReset | ConnectionAborted | ConnectionRefused | BrokenPipe | UnexpectedEof
|
||||
|
|
@ -227,7 +282,9 @@ 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),
|
||||
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}")),
|
||||
}
|
||||
}
|
||||
|
|
@ -259,13 +316,17 @@ pub fn parse_db(s: &str) -> BTreeMap<String, String> {
|
|||
}
|
||||
_ => break,
|
||||
}
|
||||
let Some(key) = parse_string(&mut chars) else { break };
|
||||
let Some(key) = parse_string(&mut chars) else {
|
||||
break;
|
||||
};
|
||||
skip_ws(&mut chars);
|
||||
if chars.next() != Some(':') {
|
||||
break;
|
||||
}
|
||||
skip_ws(&mut chars);
|
||||
let Some(value) = parse_string(&mut chars) else { break };
|
||||
let Some(value) = parse_string(&mut chars) else {
|
||||
break;
|
||||
};
|
||||
map.insert(key, value);
|
||||
}
|
||||
map
|
||||
|
|
|
|||
|
|
@ -78,7 +78,11 @@ pub fn parse_str(content: &str) -> Vec<Config> {
|
|||
assemble(base, sections, &None)
|
||||
}
|
||||
|
||||
fn assemble(base: Config, sections: Vec<(String, Vec<Entry>)>, path: &Option<PathBuf>) -> Vec<Config> {
|
||||
fn assemble(
|
||||
base: Config,
|
||||
sections: Vec<(String, Vec<Entry>)>,
|
||||
path: &Option<PathBuf>,
|
||||
) -> Vec<Config> {
|
||||
let mut configs = Vec::new();
|
||||
if sections.is_empty() {
|
||||
let mut c = base;
|
||||
|
|
@ -125,7 +129,11 @@ fn parse_sections(content: &str) -> (Vec<Entry>, Vec<(String, Vec<Entry>)>) {
|
|||
log(&format!("line {lineno}: ignoring line without '='"));
|
||||
continue;
|
||||
};
|
||||
let entry = (lineno, key.trim().to_ascii_lowercase(), value.trim().to_string());
|
||||
let entry = (
|
||||
lineno,
|
||||
key.trim().to_ascii_lowercase(),
|
||||
value.trim().to_string(),
|
||||
);
|
||||
match sections.last_mut() {
|
||||
Some((_, entries)) => entries.push(entry),
|
||||
None => toplevel.push(entry),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub fn base64_encode(input: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 64] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
|
||||
for chunk in input.chunks(3) {
|
||||
let b0 = chunk[0] as u32;
|
||||
|
|
@ -11,8 +10,16 @@ pub fn base64_encode(input: &[u8]) -> String {
|
|||
let n = (b0 << 16) | (b1 << 8) | b2;
|
||||
out.push(ALPHABET[(n >> 18 & 63) as usize] as char);
|
||||
out.push(ALPHABET[(n >> 12 & 63) as usize] as char);
|
||||
out.push(if chunk.len() > 1 { ALPHABET[(n >> 6 & 63) as usize] as char } else { '=' });
|
||||
out.push(if chunk.len() > 2 { ALPHABET[(n & 63) as usize] as char } else { '=' });
|
||||
out.push(if chunk.len() > 1 {
|
||||
ALPHABET[(n >> 6 & 63) as usize] as char
|
||||
} else {
|
||||
'='
|
||||
});
|
||||
out.push(if chunk.len() > 2 {
|
||||
ALPHABET[(n & 63) as usize] as char
|
||||
} else {
|
||||
'='
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,9 @@ fn supervise(config: Config, labelled: bool) {
|
|||
loop {
|
||||
match run_once(&config, labelled) {
|
||||
Ok(()) => {
|
||||
log(&format!("{tag}server closed the connection; reconnecting shortly"));
|
||||
log(&format!(
|
||||
"{tag}server closed the connection; reconnecting shortly"
|
||||
));
|
||||
backoff = 1;
|
||||
}
|
||||
Err(e) => log(&format!("{tag}session error: {e}; retrying in {backoff}s")),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,14 @@ use rustbot::bot::module::{Action, Command, Module};
|
|||
use rustbot::bot::modules::hash::{md5, sha1, sha256, Hash};
|
||||
|
||||
fn cmd<'a>(name: &'a str, args: &'a [&'a str]) -> Command<'a> {
|
||||
Command { sender: "u", reply_to: "#c", name, args, prefix: ">", network: "t" }
|
||||
Command {
|
||||
sender: "u",
|
||||
reply_to: "#c",
|
||||
name,
|
||||
args,
|
||||
prefix: ">",
|
||||
network: "t",
|
||||
}
|
||||
}
|
||||
|
||||
fn reply(actions: &[Action]) -> &str {
|
||||
|
|
@ -65,8 +72,14 @@ fn module_all_hashes() {
|
|||
let mut m = Hash;
|
||||
let r = reply(&m.on_command(&cmd("hash", &["abc"]))).to_string();
|
||||
assert!(r.contains("md5: 900150983cd24fb0d6963f7d28e17f72"), "{r}");
|
||||
assert!(r.contains("sha1: a9993e364706816aba3e25717850c26c9cd0d89d"), "{r}");
|
||||
assert!(r.contains("sha256: ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"), "{r}");
|
||||
assert!(
|
||||
r.contains("sha1: a9993e364706816aba3e25717850c26c9cd0d89d"),
|
||||
"{r}"
|
||||
);
|
||||
assert!(
|
||||
r.contains("sha256: ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"),
|
||||
"{r}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,14 @@ use rustbot::bot::module::{Action, Command, Module};
|
|||
use rustbot::bot::modules::{builtins::Builtins, dice::Dice};
|
||||
|
||||
fn cmd<'a>(name: &'a str, args: &'a [&'a str]) -> Command<'a> {
|
||||
Command { sender: "alice", reply_to: "#chan", name, args, prefix: ">", network: "test" }
|
||||
Command {
|
||||
sender: "alice",
|
||||
reply_to: "#chan",
|
||||
name,
|
||||
args,
|
||||
prefix: ">",
|
||||
network: "test",
|
||||
}
|
||||
}
|
||||
|
||||
fn reply(actions: &[Action]) -> &str {
|
||||
|
|
@ -21,7 +28,10 @@ fn builtins_ping_pongs() {
|
|||
#[test]
|
||||
fn builtins_echo_joins_args() {
|
||||
let mut m = Builtins;
|
||||
assert_eq!(reply(&m.on_command(&cmd("echo", &["hello", "world"]))), "hello world");
|
||||
assert_eq!(
|
||||
reply(&m.on_command(&cmd("echo", &["hello", "world"]))),
|
||||
"hello world"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -43,7 +53,10 @@ fn dice_2d6_total_is_in_range() {
|
|||
for _ in 0..300 {
|
||||
let r = reply(&d.on_command(&cmd("roll", &["2d6"]))).to_string();
|
||||
let total: u64 = r.rsplit('=').next().unwrap().trim().parse().unwrap();
|
||||
assert!((2..=12).contains(&total), "2d6 total {total} out of range: {r:?}");
|
||||
assert!(
|
||||
(2..=12).contains(&total),
|
||||
"2d6 total {total} out of range: {r:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -61,8 +61,14 @@ fn base64_matches_known_vectors() {
|
|||
#[test]
|
||||
fn server_time_parses_to_unix_seconds() {
|
||||
assert_eq!(parse_server_time("1970-01-01T00:00:00.000Z"), Some(0));
|
||||
assert_eq!(parse_server_time("2020-01-01T00:00:00Z"), Some(1_577_836_800));
|
||||
assert_eq!(parse_server_time("2021-01-01T00:00:00.123Z"), Some(1_609_459_200));
|
||||
assert_eq!(
|
||||
parse_server_time("2020-01-01T00:00:00Z"),
|
||||
Some(1_577_836_800)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_server_time("2021-01-01T00:00:00.123Z"),
|
||||
Some(1_609_459_200)
|
||||
);
|
||||
assert_eq!(parse_server_time("not-a-timestamp"), None);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,14 @@ use rustbot::bot::module::{Action, Command, Module};
|
|||
use rustbot::bot::modules::weather::{format_weather, parse_db, to_json, Weather};
|
||||
|
||||
fn cmd<'a>(sender: &'a str, name: &'a str, args: &'a [&'a str]) -> Command<'a> {
|
||||
Command { sender, reply_to: "#chan", name, args, prefix: ">", network: "test" }
|
||||
Command {
|
||||
sender,
|
||||
reply_to: "#chan",
|
||||
name,
|
||||
args,
|
||||
prefix: ">",
|
||||
network: "test",
|
||||
}
|
||||
}
|
||||
|
||||
fn reply(actions: &[Action]) -> &str {
|
||||
|
|
@ -36,7 +43,10 @@ fn parse_db_tolerates_junk() {
|
|||
assert!(parse_db("").is_empty());
|
||||
assert!(parse_db("not json").is_empty());
|
||||
assert_eq!(parse_db("{}").len(), 0);
|
||||
assert_eq!(parse_db("{\"a\":\"b\"}").get("a").map(String::as_str), Some("b"));
|
||||
assert_eq!(
|
||||
parse_db("{\"a\":\"b\"}").get("a").map(String::as_str),
|
||||
Some("b")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue