Format with rustfmt
All checks were successful
ci / check (push) Successful in 44s

This commit is contained in:
Jean Chevronnet 2026-07-29 19:17:16 +00:00
parent facbde55bd
commit 7846f91afe
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
11 changed files with 209 additions and 54 deletions

View file

@ -8,7 +8,9 @@ const HISTORY_GRACE_SECS: u64 = 60;
impl Bot { impl Bot {
pub(super) fn handle_batch(&mut self, msg: &Message) { 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('+') { if let Some(name) = reference.strip_prefix('+') {
self.batches self.batches
.insert(name.to_string(), msg.param(1).unwrap_or("").to_string()); .insert(name.to_string(), msg.param(1).unwrap_or("").to_string());
@ -38,7 +40,9 @@ impl Bot {
return Ok(()); 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 text = msg.trailing().unwrap_or("");
let sender = msg.nick().unwrap_or(""); let sender = msg.nick().unwrap_or("");

View file

@ -3,9 +3,21 @@ use crate::bot::module::{Action, Command, CommandSpec, Module};
pub struct Builtins; pub struct Builtins;
const COMMANDS: &[CommandSpec] = &[ const COMMANDS: &[CommandSpec] = &[
CommandSpec { name: "ping", usage: "ping", about: "reply with 'pong'" }, CommandSpec {
CommandSpec { name: "echo", usage: "echo <text>", about: "echo the text back" }, name: "ping",
CommandSpec { name: "hello", usage: "hello", about: "greet the sender" }, 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 { impl Module for Builtins {

View file

@ -3,10 +3,26 @@ use crate::bot::module::{Action, Command, CommandSpec, Module};
pub struct Hash; pub struct Hash;
const COMMANDS: &[CommandSpec] = &[ const COMMANDS: &[CommandSpec] = &[
CommandSpec { name: "md5", usage: "md5 <text>", about: "MD5 hex digest of <text>" }, CommandSpec {
CommandSpec { name: "sha1", usage: "sha1 <text>", about: "SHA1 hex digest of <text>" }, name: "md5",
CommandSpec { name: "sha256", usage: "sha256 <text>", about: "SHA256 hex digest of <text>" }, usage: "md5 <text>",
CommandSpec { name: "hash", usage: "hash <text>", about: "md5, sha1 and sha256 of <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 { impl Module for Hash {
@ -21,7 +37,10 @@ impl Module for Hash {
fn on_command(&mut self, cmd: &Command) -> Vec<Action> { fn on_command(&mut self, cmd: &Command) -> Vec<Action> {
let input = cmd.args.join(" "); let input = cmd.args.join(" ");
if input.is_empty() { 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 b = input.as_bytes();
let reply = match cmd.name { let reply = match cmd.name {

View file

@ -14,8 +14,16 @@ use crate::bot::module::{Action, Command, CommandSpec, Module};
const CACHE_TTL: Duration = Duration::from_secs(600); const CACHE_TTL: Duration = Duration::from_secs(600);
const COMMANDS: &[CommandSpec] = &[ const COMMANDS: &[CommandSpec] = &[
CommandSpec { name: "add", usage: "add <location>", about: "save your weather location" }, CommandSpec {
CommandSpec { name: "w", usage: "w [location]", about: "weather for your saved location" }, 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 { pub struct Weather {
@ -35,7 +43,11 @@ impl Weather {
} }
pub fn with_path(path: PathBuf) -> 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> { pub fn location_of(&self, nick: &str) -> Option<&str> {
@ -56,15 +68,27 @@ impl Module for Weather {
match cmd.name { match cmd.name {
"add" => { "add" => {
if cmd.args.is_empty() { 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(" "); let loc = cmd.args.join(" ");
self.store.set(cmd.sender, &loc); self.store.set(cmd.sender, &loc);
if let Err(e) = self.store.save(&self.path) { if let Err(e) = self.store.save(&self.path) {
crate::log(&format!("weather: could not save {}: {e}", self.path.display())); crate::log(&format!(
return vec![Action::reply(format!("{}: could not save location", cmd.sender))]; "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" => { "w" => {
let loc = if !cmd.args.is_empty() { let loc = if !cmd.args.is_empty() {
@ -104,7 +128,9 @@ impl Store {
fn load(path: &Path) -> Store { fn load(path: &Path) -> Store {
match fs::read_to_string(path) { match fs::read_to_string(path) {
Ok(s) => Store { map: parse_db(&s) }, 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) { match https_get("wttr.in", &path) {
Ok(body) => { 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() { if !raw.is_empty() {
return Ok(format_weather(raw, location)); return Ok(format_weather(raw, location));
} }
@ -174,32 +204,52 @@ struct HttpErr {
fn https_get(host: &str, path: &str) -> Result<String, HttpErr> { fn https_get(host: &str, path: &str) -> Result<String, HttpErr> {
let addr = (host, 443) let addr = (host, 443)
.to_socket_addrs() .to_socket_addrs()
.map_err(|e| HttpErr { msg: e.to_string(), retry: false })? .map_err(|e| HttpErr {
msg: e.to_string(),
retry: false,
})?
.next() .next()
.ok_or(HttpErr { msg: "dns lookup failed".to_string(), retry: false })?; .ok_or(HttpErr {
let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(4)) msg: "dns lookup failed".to_string(),
.map_err(|e| HttpErr { retry: retryable_io(&e), msg: e.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_read_timeout(Some(Duration::from_secs(5))).ok();
stream.set_write_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 connector = TlsConnector::new().map_err(|e| HttpErr {
let mut tls = connector msg: e.to_string(),
.connect(host, stream) retry: false,
.map_err(|e| HttpErr { msg: e.to_string(), retry: true })?; })?;
let mut tls = connector.connect(host, stream).map_err(|e| HttpErr {
msg: e.to_string(),
retry: true,
})?;
let req = format!( 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" "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()) tls.write_all(req.as_bytes()).map_err(|e| HttpErr {
.map_err(|e| HttpErr { retry: retryable_io(&e), msg: e.to_string() })?; retry: retryable_io(&e),
msg: e.to_string(),
})?;
let mut buf = Vec::new(); let mut buf = Vec::new();
tls.read_to_end(&mut buf) tls.read_to_end(&mut buf).map_err(|e| HttpErr {
.map_err(|e| HttpErr { retry: retryable_io(&e), msg: e.to_string() })?; retry: retryable_io(&e),
msg: e.to_string(),
})?;
let text = String::from_utf8_lossy(&buf); let text = String::from_utf8_lossy(&buf);
let Some((head, body)) = text.split_once("\r\n\r\n") else { 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 let code = head
.lines() .lines()
@ -210,13 +260,18 @@ fn https_get(host: &str, path: &str) -> Result<String, HttpErr> {
.and_then(|c| c.parse::<u16>().ok()) .and_then(|c| c.parse::<u16>().ok())
.unwrap_or(0); .unwrap_or(0);
if !(200..300).contains(&code) { 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()) Ok(body.to_string())
} }
fn retryable_io(e: &io::Error) -> bool { fn retryable_io(e: &io::Error) -> bool {
use io::ErrorKind::{BrokenPipe, ConnectionAborted, ConnectionRefused, ConnectionReset, UnexpectedEof}; use io::ErrorKind::{
BrokenPipe, ConnectionAborted, ConnectionRefused, ConnectionReset, UnexpectedEof,
};
matches!( matches!(
e.kind(), e.kind(),
ConnectionReset | ConnectionAborted | ConnectionRefused | BrokenPipe | UnexpectedEof ConnectionReset | ConnectionAborted | ConnectionRefused | BrokenPipe | UnexpectedEof
@ -227,7 +282,9 @@ fn url_encode(s: &str) -> String {
let mut out = String::new(); let mut out = String::new();
for b in s.bytes() { for b in s.bytes() {
match b { 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}")), _ => out.push_str(&format!("%{b:02X}")),
} }
} }
@ -259,13 +316,17 @@ pub fn parse_db(s: &str) -> BTreeMap<String, String> {
} }
_ => break, _ => break,
} }
let Some(key) = parse_string(&mut chars) else { break }; let Some(key) = parse_string(&mut chars) else {
break;
};
skip_ws(&mut chars); skip_ws(&mut chars);
if chars.next() != Some(':') { if chars.next() != Some(':') {
break; break;
} }
skip_ws(&mut chars); 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.insert(key, value);
} }
map map

View file

@ -78,7 +78,11 @@ pub fn parse_str(content: &str) -> Vec<Config> {
assemble(base, sections, &None) 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(); let mut configs = Vec::new();
if sections.is_empty() { if sections.is_empty() {
let mut c = base; 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 '='")); log(&format!("line {lineno}: ignoring line without '='"));
continue; 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() { match sections.last_mut() {
Some((_, entries)) => entries.push(entry), Some((_, entries)) => entries.push(entry),
None => toplevel.push(entry), None => toplevel.push(entry),

View file

@ -1,8 +1,7 @@
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
pub fn base64_encode(input: &[u8]) -> String { pub fn base64_encode(input: &[u8]) -> String {
const ALPHABET: &[u8; 64] = const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(input.len().div_ceil(3) * 4); let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
for chunk in input.chunks(3) { for chunk in input.chunks(3) {
let b0 = chunk[0] as u32; let b0 = chunk[0] as u32;
@ -11,8 +10,16 @@ pub fn base64_encode(input: &[u8]) -> String {
let n = (b0 << 16) | (b1 << 8) | b2; let n = (b0 << 16) | (b1 << 8) | b2;
out.push(ALPHABET[(n >> 18 & 63) as usize] as char); out.push(ALPHABET[(n >> 18 & 63) as usize] as char);
out.push(ALPHABET[(n >> 12 & 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() > 1 {
out.push(if chunk.len() > 2 { ALPHABET[(n & 63) as usize] as char } else { '=' }); ALPHABET[(n >> 6 & 63) as usize] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
ALPHABET[(n & 63) as usize] as char
} else {
'='
});
} }
out out
} }

View file

@ -50,7 +50,9 @@ fn supervise(config: Config, labelled: bool) {
loop { loop {
match run_once(&config, labelled) { match run_once(&config, labelled) {
Ok(()) => { Ok(()) => {
log(&format!("{tag}server closed the connection; reconnecting shortly")); log(&format!(
"{tag}server closed the connection; reconnecting shortly"
));
backoff = 1; backoff = 1;
} }
Err(e) => log(&format!("{tag}session error: {e}; retrying in {backoff}s")), Err(e) => log(&format!("{tag}session error: {e}; retrying in {backoff}s")),

View file

@ -2,7 +2,14 @@ use rustbot::bot::module::{Action, Command, Module};
use rustbot::bot::modules::hash::{md5, sha1, sha256, Hash}; use rustbot::bot::modules::hash::{md5, sha1, sha256, Hash};
fn cmd<'a>(name: &'a str, args: &'a [&'a str]) -> Command<'a> { 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 { fn reply(actions: &[Action]) -> &str {
@ -65,8 +72,14 @@ fn module_all_hashes() {
let mut m = Hash; let mut m = Hash;
let r = reply(&m.on_command(&cmd("hash", &["abc"]))).to_string(); let r = reply(&m.on_command(&cmd("hash", &["abc"]))).to_string();
assert!(r.contains("md5: 900150983cd24fb0d6963f7d28e17f72"), "{r}"); assert!(r.contains("md5: 900150983cd24fb0d6963f7d28e17f72"), "{r}");
assert!(r.contains("sha1: a9993e364706816aba3e25717850c26c9cd0d89d"), "{r}"); assert!(
assert!(r.contains("sha256: ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"), "{r}"); r.contains("sha1: a9993e364706816aba3e25717850c26c9cd0d89d"),
"{r}"
);
assert!(
r.contains("sha256: ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"),
"{r}"
);
} }
#[test] #[test]

View file

@ -2,7 +2,14 @@ use rustbot::bot::module::{Action, Command, Module};
use rustbot::bot::modules::{builtins::Builtins, dice::Dice}; use rustbot::bot::modules::{builtins::Builtins, dice::Dice};
fn cmd<'a>(name: &'a str, args: &'a [&'a str]) -> Command<'a> { 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 { fn reply(actions: &[Action]) -> &str {
@ -21,7 +28,10 @@ fn builtins_ping_pongs() {
#[test] #[test]
fn builtins_echo_joins_args() { fn builtins_echo_joins_args() {
let mut m = Builtins; 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] #[test]
@ -43,7 +53,10 @@ fn dice_2d6_total_is_in_range() {
for _ in 0..300 { for _ in 0..300 {
let r = reply(&d.on_command(&cmd("roll", &["2d6"]))).to_string(); let r = reply(&d.on_command(&cmd("roll", &["2d6"]))).to_string();
let total: u64 = r.rsplit('=').next().unwrap().trim().parse().unwrap(); 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:?}"
);
} }
} }

View file

@ -61,8 +61,14 @@ fn base64_matches_known_vectors() {
#[test] #[test]
fn server_time_parses_to_unix_seconds() { 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("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!(
assert_eq!(parse_server_time("2021-01-01T00:00:00.123Z"), Some(1_609_459_200)); 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); assert_eq!(parse_server_time("not-a-timestamp"), None);
} }

View file

@ -5,7 +5,14 @@ use rustbot::bot::module::{Action, Command, Module};
use rustbot::bot::modules::weather::{format_weather, parse_db, to_json, Weather}; 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> { 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 { fn reply(actions: &[Action]) -> &str {
@ -36,7 +43,10 @@ fn parse_db_tolerates_junk() {
assert!(parse_db("").is_empty()); assert!(parse_db("").is_empty());
assert!(parse_db("not json").is_empty()); assert!(parse_db("not json").is_empty());
assert_eq!(parse_db("{}").len(), 0); 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] #[test]